🎉 First commit !!!

This commit is contained in:
2023-01-22 16:53:45 +01:00
commit dde835760a
68 changed files with 3250 additions and 0 deletions

1
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
!**/__pycache__/

0
backend/README.md Normal file
View File

View File

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,11 @@
#!/bin/bash
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;
EOSQL

View File

@@ -0,0 +1,4 @@
from .db import Database
from .base_class import Base
db = Database()

View File

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,80 @@
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

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

View File

@@ -0,0 +1,447 @@
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

@@ -0,0 +1,277 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum, StrEnum
from typing import Any, Literal, Optional, NamedTuple
from msgspec import Struct
class Coordinate(NamedTuple):
lat: float
lon: float
class IdfmState(Enum):
unknown = "unknown"
false = "false"
partial = "partial"
true = "true"
class TrainStatus(Enum):
unknown = ""
arrived = "arrived"
onTime = "onTime"
delayed = "delayed"
noReport = "noReport"
early = "early"
cancelled = "cancelled"
undefined = "undefined"
class TransportMode(StrEnum):
bus = "bus"
tram = "tram"
metro = "metro"
rail = "rail"
funicular = "funicular"
class TransportSubMode(Enum):
unknown = "unknown"
localBus = "localBus"
regionalBus = "regionalBus"
highFrequencyBus = "highFrequencyBus"
expressBus = "expressBus"
nightBus = "nightBus"
demandAndResponseBus = "demandAndResponseBus"
airportLinkBus = "airportLinkBus"
regionalRail = "regionalRail"
railShuttle = "railShuttle"
suburbanRailway = "suburbanRailway"
local = "local"
class StopFields(Struct, kw_only=True):
arrgeopoint: Coordinate
arrtown: str
arrcreated: None | datetime = None
arryepsg2154: int
arrpostalregion: str
arrid: str
arrxepsg2154: int
arraccessibility: IdfmState
arrvisualsigns: IdfmState
arrtype: TransportMode
arrname: str
arrversion: str
arrchanged: datetime
arraudiblesignals: IdfmState
class Point(Struct):
coordinates: Coordinate
class Stop(Struct):
datasetid: str
recordid: str
fields: StopFields
record_timestamp: datetime
# geometry: Union[Point]
Stops = dict[str, Stop]
class StopAreaType(Enum):
metroStation = "metroStation"
onstreetBus = "onstreetBus"
onstreetTram = "onstreetTram"
railStation = "railStation"
class StopAreaFields(Struct, kw_only=True):
zdaname: str
zdcid: str
zdatown: str
zdaversion: str
zdaid: str
zdacreated: Optional[datetime] = None
zdatype: StopAreaType
zdayepsg2154: int
zdapostalregion: str
zdachanged: datetime
zdaxepsg2154: int
class StopArea(Struct):
datasetid: str
recordid: str
fields: StopAreaFields
record_timestamp: datetime
class StopAreaStopAssociationFields(Struct, kw_only=True):
arrid: str # TODO: use int ?
artid: Optional[str] = None
arrversion: str
zdcid: str
version: int
zdaid: str
zdaversion: str
artversion: Optional[str] = None
class StopAreaStopAssociation(Struct):
datasetid: str
recordid: str
fields: StopAreaStopAssociationFields
record_timestamp: datetime
class IdfmLineState(Enum):
active = "active"
class LinePicto(Struct, rename={"id_": "id"}):
id_: str
mimetype: str
height: int
width: int
filename: str
thumbnail: bool
format: str
# color_summary: list[str]
class LineFields(Struct, kw_only=True):
name_line: str
status: IdfmLineState
accessibility: IdfmState
shortname_groupoflines: Optional[str] = None
transportmode: TransportMode
colourweb_hexa: str
textcolourprint_hexa: str
transportsubmode: Optional[TransportSubMode] = TransportSubMode.unknown
operatorref: Optional[str] = None
visualsigns_available: IdfmState
networkname: Optional[str] = None
id_line: str
id_groupoflines: Optional[str] = None
operatorname: Optional[str] = None
audiblesigns_available: IdfmState
shortname_line: str
picto: Optional[LinePicto] = None
class Line(Struct):
datasetid: str
recordid: str
fields: LineFields
record_timestamp: datetime
@staticmethod
def optional_value(value: Any) -> Any:
if value:
return value.value if isinstance(value, Enum) else value
return "NULL"
Lines = dict[str, Line]
# TODO: Set structs frozen
class StopLineAssoFields(Struct):
pointgeo: Coordinate
stop_id: str
stop_name: str
operatorname: str
nom_commune: str
route_long_name: str
id: str
stop_lat: str
stop_lon: str
code_insee: str
class StopLineAsso(Struct):
datasetid: str
recordid: str
fields: StopLineAssoFields
# geometry: Union[Point]
class Value(Struct):
value: str
class FramedVehicleJourney(Struct):
DataFrameRef: Value
DatedVehicleJourneyRef: str
class TrainNumber(Struct):
TrainNumberRef: list[Value]
class MonitoredCall(Struct, kw_only=True):
Order: Optional[int] = 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
class MonitoredVehicleJourney(Struct, kw_only=True):
LineRef: Value
OperatorRef: Value
FramedVehicleJourneyRef: FramedVehicleJourney
DestinationRef: Value
DestinationName: list[Value] | None = None
JourneyNote: list[Value] | None = None
TrainNumbers: Optional[TrainNumber] = None
MonitoredCall: MonitoredCall
class StopDelivery(Struct):
RecordedAtTime: datetime
ItemIdentifier: str
MonitoringRef: Value
MonitoredVehicleJourney: MonitoredVehicleJourney
class StopMonitoringDelivery(Struct):
ResponseTimestamp: datetime
Version: str
Status: IdfmState
MonitoredStopVisit: list[StopDelivery]
class ServiceDelivery(Struct):
ResponseTimestamp: datetime
ProducerRef: str
ResponseMessageIdentifier: str
StopMonitoringDelivery: list[StopMonitoringDelivery]
class Siri(Struct):
ServiceDelivery: ServiceDelivery
class IdfmOperator(Enum):
SNCF = "SNCF"
class IdfmResponse(Struct):
Siri: Siri

View File

@@ -0,0 +1,25 @@
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

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

View File

@@ -0,0 +1,176 @@
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

@@ -0,0 +1,144 @@
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

@@ -0,0 +1,25 @@
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

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

View File

@@ -0,0 +1,60 @@
from enum import StrEnum
from typing import Self
from pydantic import BaseModel
from ..idfm_interface import (
IdfmLineState,
IdfmState,
TransportMode as IdfmTransportMode,
TransportSubMode as IdfmTransportSubMode,
)
class TransportMode(StrEnum):
"""Computed transport mode from
idfm_interface.TransportMode and idfm_interface.TransportSubMode.
"""
bus = "bus"
tram = "tram"
metro = "metro"
funicular = "funicular"
# idfm_types.TransportMode.rail + idfm_types.TransportSubMode.regionalRail
rail_ter = "ter"
# idfm_types.TransportMode.rail + idfm_types.TransportSubMode.local
rail_rer = "rer"
# idfm_types.TransportMode.rail + idfm_types.TransportSubMode.suburbanRailway
rail_transilien = "transilien"
# idfm_types.TransportMode.rail + idfm_types.TransportSubMode.railShuttle
val = "val"
@classmethod
def from_idfm_transport_mode(
cls, mode: IdfmTransportMode, sub_mode: IdfmTransportSubMode
) -> Self:
if mode == IdfmTransportMode.rail:
if sub_mode == IdfmTransportSubMode.regionalRail:
return cls.rail_ter
if sub_mode == IdfmTransportSubMode.local:
return cls.rail_rer
if sub_mode == IdfmTransportSubMode.suburbanRailway:
return cls.rail_transilien
if sub_mode == IdfmTransportSubMode.railShuttle:
return cls.val
return TransportMode(mode)
class Line(BaseModel):
id: str
shortName: str
name: str
status: IdfmLineState
transportMode: TransportMode
backColorHexa: str
foreColorHexa: str
operatorId: str
accessibility: IdfmState
visualSignsAvailable: IdfmState
audibleSignsAvailable: IdfmState
stopIds: list[str]

View File

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,25 @@
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]

208
backend/main.py Normal file
View File

@@ -0,0 +1,208 @@
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,
)

58
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,58 @@
[tool.poetry]
name = "idfm-matrix-widget"
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"
uvicorn = "^0.20.0"
asyncpg = "^0.27.0"
msgspec = "^0.12.0"
[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]
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"
[tool.pylsp-mypy]
enabled = true
[mypy]
plugins = "sqlmypy"
[pycodestyle]
max_line_length = 100
[pylint]
max-line-length = 100

23
frontend/.eslintrc.js Normal file
View File

@@ -0,0 +1,23 @@
module.exports = {
"env": {
"node": true,
"browser": true,
"es2021": true
},
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:solid/typescript",
],
"overrides": [
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"solid"
],
"rules": {
}
}

3
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
package-lock.json

34
frontend/README.md Normal file
View File

@@ -0,0 +1,34 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm dev` or `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<title>Métro-Boulot-Dodo</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

33
frontend/package.json Normal file
View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46469 283.46466"><defs><style>.cls-1{fill:#231f20;}.cls-2{fill:#00643c;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M141.73236,0C63.58334,0,0,63.57928,0,141.73236c0,78.149,63.58334,141.7323,141.73236,141.7323,78.15305,0,141.73233-63.58331,141.73233-141.7323C283.46469,63.57928,219.88541,0,141.73236,0Zm.00018,265.09479A123.36231,123.36231,0,1,1,265.09436,141.73248,123.5019,123.5019,0,0,1,141.73254,265.09479Z"/><circle class="cls-2" cx="141.73528" cy="141.73235" r="123.36528"/></g></g></svg>

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46469 283.46466"><defs><style>.cls-1{fill:#231f20;}.cls-2{fill:#ffbe00;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M141.73236,0C63.58334,0,0,63.57928,0,141.73236c0,78.149,63.58334,141.7323,141.73236,141.7323,78.15305,0,141.73233-63.58331,141.73233-141.7323C283.46469,63.57928,219.88541,0,141.73236,0Zm.00018,265.09479A123.36231,123.36231,0,1,1,265.09436,141.73248,123.5019,123.5019,0,0,1,141.73254,265.09479Z"/><circle class="cls-2" cx="141.73528" cy="141.73235" r="123.36528"/></g></g></svg>

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46469 283.46466"><defs><style>.cls-1{fill:#231f20;}.cls-2{fill:#ff5a00;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M141.73236,0C63.58334,0,0,63.57928,0,141.73236c0,78.149,63.58334,141.7323,141.73236,141.7323,78.15305,0,141.73233-63.58331,141.73233-141.7323C283.46469,63.57928,219.88541,0,141.73236,0Zm.00018,265.09479A123.36231,123.36231,0,1,1,265.09436,141.73248,123.5019,123.5019,0,0,1,141.73254,265.09479Z"/><circle class="cls-2" cx="141.73528" cy="141.73235" r="123.36528"/></g></g></svg>

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46 283.46"><g id="Layer_1-2"><g><path d="M244.14,161.22c0-14.53-8.15-23.03-18.42-28.7l-7.8-4.25c-4.6-2.48-8.5-6.38-8.5-11.34,0-5.67,4.61-9.56,11.34-9.56,4.61,0,8.86,1.77,12.05,3.19,4.96,2.48,8.15-.71,9.57-6.02,1.77-6.03-.71-8.15-4.96-10.27-4.25-2.13-10.27-3.55-16.29-3.55-16.66,0-30.48,10.98-30.48,28.7,0,13.82,7.08,21.62,18.43,27.99l8.15,4.61c4.96,2.84,8.15,6.74,8.15,12.05,0,6.73-6.38,9.92-12.76,9.92-4.96,0-10.98-2.13-14.18-3.55-5.31-2.48-8.15,.71-9.92,6.38-1.76,6.02,1.07,8.51,5.67,10.63,4.25,2.12,11.69,3.89,18.43,3.89,17.36,0,31.53-11.69,31.53-30.12m-62-3.55v-58.82c0-4.96-4.96-7.44-9.57-7.44-4.96,0-9.56,2.48-9.56,7.44v56.34c0,11.34-3.55,18.78-12.76,18.78s-13.11-7.09-13.11-18.78v-56.34c0-4.96-5.31-7.44-9.92-7.44-5.31,0-9.92,2.48-9.92,7.44v58.46c0,23.39,11.69,34.02,32.95,34.02,19.13,0,31.89-10.98,31.89-33.66m-74.06,4.26c0-16.31-11.33-22.33-17.36-23.39v-.35c7.09-3.55,15.59-10.28,15.59-22.33,0-16.29-14.18-24.09-27.99-24.09h-26.58c-4.96,0-7.79,3.89-7.79,8.15v82.91c0,4.96,4.96,7.45,9.57,7.45h25.15c15.24,0,29.41-8.15,29.41-28.35m-21.61-41.46c0,9.21-7.79,12.4-15.94,12.4h-7.44v-24.81h7.44c8.5,0,15.94,2.84,15.94,12.41m2.84,39.33c0,9.57-5.32,14.18-16.65,14.18h-8.51v-27.64h8.51c7.09,0,16.65,2.48,16.65,13.46M274.25,18.43H9.21C4.13,18.43,0,14.3,0,9.22S4.13,0,9.21,0H274.25c5.09,0,9.21,4.13,9.21,9.22s-4.12,9.21-9.21,9.21m0,265.04H9.21c-5.08,0-9.21-4.12-9.21-9.21s4.13-9.21,9.21-9.21H274.25c5.09,0,9.21,4.12,9.21,9.21s-4.12,9.21-9.21,9.21"/><path d="M244.14,161.22c0-14.53-8.15-23.03-18.42-28.7l-7.8-4.25c-4.6-2.48-8.5-6.38-8.5-11.34,0-5.67,4.61-9.56,11.34-9.56,4.61,0,8.86,1.77,12.05,3.19,4.96,2.48,8.15-.71,9.57-6.02,1.77-6.03-.71-8.15-4.96-10.27-4.25-2.13-10.27-3.55-16.29-3.55-16.66,0-30.48,10.98-30.48,28.7,0,13.82,7.08,21.62,18.43,27.99l8.15,4.61c4.96,2.84,8.15,6.74,8.15,12.05,0,6.73-6.38,9.92-12.76,9.92-4.96,0-10.98-2.13-14.18-3.55-5.31-2.48-8.15,.71-9.92,6.38-1.76,6.02,1.07,8.51,5.67,10.63,4.25,2.12,11.69,3.89,18.43,3.89,17.36,0,31.53-11.69,31.53-30.12m-62-3.55v-58.82c0-4.96-4.96-7.44-9.57-7.44-4.96,0-9.56,2.48-9.56,7.44v56.34c0,11.34-3.55,18.78-12.76,18.78s-13.11-7.09-13.11-18.78v-56.34c0-4.96-5.31-7.44-9.92-7.44-5.31,0-9.92,2.48-9.92,7.44v58.46c0,23.39,11.69,34.02,32.95,34.02,19.13,0,31.89-10.98,31.89-33.66m-74.06,4.26c0-16.31-11.33-22.33-17.36-23.39v-.35c7.09-3.55,15.59-10.28,15.59-22.33,0-16.29-14.18-24.09-27.99-24.09h-26.58c-4.96,0-7.79,3.89-7.79,8.15v82.91c0,4.96,4.96,7.45,9.57,7.45h25.15c15.24,0,29.41-8.15,29.41-28.35m-21.61-41.46c0,9.21-7.79,12.4-15.94,12.4h-7.44v-24.81h7.44c8.5,0,15.94,2.84,15.94,12.41m2.84,39.33c0,9.57-5.32,14.18-16.65,14.18h-8.51v-27.64h8.51c7.09,0,16.65,2.48,16.65,13.46M274.25,18.43H9.21C4.13,18.43,0,14.3,0,9.22S4.13,0,9.21,0H274.25c5.09,0,9.21,4.13,9.21,9.22s-4.12,9.21-9.21,9.21m0,265.04H9.21c-5.08,0-9.21-4.12-9.21-9.21s4.13-9.21,9.21-9.21H274.25c5.09,0,9.21,4.12,9.21,9.21s-4.12,9.21-9.21,9.21"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 283.465 283.465" enable-background="new 0 0 283.465 283.465" xml:space="preserve">
<g>
<path fill="#FFFFFF" d="M165.206,155.193V97.366c0-2.976,3.686-5.883,7.655-5.883c3.683,0,7.653,2.907,7.653,5.883v60.307
c0,22.675-10.34,33.447-30.278,33.447c-22.063,0-31.569-10.415-31.569-33.801V97.366c0-2.976,3.688-5.883,7.938-5.883
c3.683,0,7.936,2.907,7.936,5.883v57.827c0,18.598,9.528,22.039,15.695,22.039C157.457,177.232,165.206,172.823,165.206,155.193
M72.638,144.988c11.66,0,18.621,5.768,18.621,15.431c0,10.709-6.265,16.139-18.621,16.139H60.913v-31.57H72.638z M89.899,138.539
v-0.354c6.654-3.594,14.587-10.39,14.587-22.355c0-16.532-13.57-24.063-28.79-24.062H53.605c-6.58-0.001-8.849,3.899-8.849,8.151
v82.91c0,4.961,3.969,7.442,10.063,7.442h21.329c27.936,0,29.912-21.844,29.912-28.538c0-19.969-15.493-23.068-16.161-23.196
M60.064,134.216v-28.415h9.248c11.447,0,18.952,5.046,18.952,14.208c0,8.896-7.837,14.207-18.952,14.207H60.064z M220.59,90.846
c-15.719,0.274-28.971,11.133-28.683,27.623c0.226,12.935,7.566,21.01,18.376,26.82l8.348,4.523
c5.794,3.191,9.859,6.681,9.977,13.386c0.155,8.884-7.359,13.902-15.429,14.043c-5.236,0.091-11.336-1.793-14.717-3.3
c-4.39-1.915-6.486,0.76-7.854,5.34c-1.37,4.863,0.986,6.812,4.788,8.452c4.238,2.008,11.675,3.555,18.314,3.44
c16.308-0.285,29.582-11.151,29.28-28.437c-0.238-13.629-7.92-21.829-17.816-27.082l-8.072-4.27
c-5.609-2.985-10.214-7.567-10.325-13.876c-0.139-8.03,5.632-13.83,13.763-13.972c4.878-0.085,9.396,1.701,12.811,3.328
c4.002,1.875,6.425-0.707,7.487-4.892c1.335-4.747-0.677-6.379-4.107-7.986C232.492,91.979,226.552,90.742,220.59,90.846
M283.465,7.37L283.465,7.37c0-4.07-3.3-7.37-7.37-7.37H7.37C3.3,0,0,3.3,0,7.37c0,4.071,3.3,7.371,7.37,7.371h268.725
C280.165,14.741,283.465,11.441,283.465,7.37 M283.465,276.095L283.465,276.095c0-4.07-3.3-7.37-7.37-7.37H7.37
c-4.07,0-7.37,3.3-7.37,7.37c0,4.07,3.3,7.37,7.37,7.37h268.725C280.165,283.465,283.465,280.165,283.465,276.095"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46 283.46"><g id="Layer_1-2"><g><path d="M274.25,18.43H9.21C4.12,18.43,0,14.31,0,9.21S4.12,0,9.21,0H274.25c5.09,0,9.21,4.12,9.21,9.21s-4.12,9.22-9.21,9.22m9.21,255.82h0c0-5.09-4.12-9.21-9.21-9.21H9.21c-5.09,0-9.21,4.12-9.21,9.21s4.12,9.21,9.21,9.21H274.25c5.09,0,9.21-4.12,9.21-9.21M230.98,62.08l-84.43,14.89v36.42h38.9c11.2,0,21.21,6.69,25.5,17.03,5.7,13.69,8.59,28.23,8.59,43.2s-2.89,29.51-8.6,43.2c-4.29,10.34-14.29,17.03-25.49,17.03H98.02c-11.2,0-21.21-6.69-25.51-17.03-5.7-13.69-8.59-28.23-8.59-43.2s2.89-29.51,8.59-43.21c4.29-10.34,14.31-17.03,25.51-17.03h38.89v-34.72l-81.07,14.29c-1.75,.31-3.42-.86-3.72-2.61l-.56-3.17c-.31-1.74,.85-3.41,2.6-3.71l66.63-11.75-.29-1.67c-.76-4.32,2.12-8.44,6.44-9.19l26.24-4.63c4.32-.76,8.44,2.12,9.19,6.44l.3,1.67,66.63-11.75c1.74-.31,3.4,.85,3.71,2.59l.56,3.18c.31,1.75-.86,3.41-2.6,3.71m-116.88,120.76v-54.72c0-1.09-.89-1.98-1.99-1.98h-14.09c-6.03,0-11.41,3.59-13.72,9.16-5.06,12.15-7.62,25.04-7.62,38.32,0,3.16,.15,6.31,.45,9.42,.09,1.02,.96,1.79,1.98,1.79h33.01c1.1,0,1.99-.89,1.99-1.99m57.26,1.99h33.01c1.02,0,1.88-.77,1.98-1.79,.29-3.11,.45-6.26,.45-9.42,0-13.28-2.56-26.17-7.62-38.32-2.31-5.57-7.69-9.16-13.72-9.16h-14.09c-1.09,0-1.98,.89-1.98,1.98v54.72c0,1.1,.89,1.99,1.98,1.99m-14.74,34.29v-90.99c0-1.09-.89-1.98-1.98-1.98h-25.8c-1.09,0-1.98,.89-1.98,1.98v90.99c0,1.09,.89,1.98,1.98,1.98h25.8c1.09,0,1.98-.89,1.98-1.98"/><path d="M274.25,18.43H9.21C4.12,18.43,0,14.31,0,9.21S4.12,0,9.21,0H274.25c5.09,0,9.21,4.12,9.21,9.21s-4.12,9.22-9.21,9.22m9.21,255.82h0c0-5.09-4.12-9.21-9.21-9.21H9.21c-5.09,0-9.21,4.12-9.21,9.21s4.12,9.21,9.21,9.21H274.25c5.09,0,9.21-4.12,9.21-9.21M230.98,62.08l-84.43,14.89v36.42h38.9c11.2,0,21.21,6.69,25.5,17.03,5.7,13.69,8.59,28.23,8.59,43.2s-2.89,29.51-8.6,43.2c-4.29,10.34-14.29,17.03-25.49,17.03H98.02c-11.2,0-21.21-6.69-25.51-17.03-5.7-13.69-8.59-28.23-8.59-43.2s2.89-29.51,8.59-43.21c4.29-10.34,14.31-17.03,25.51-17.03h38.89v-34.72l-81.07,14.29c-1.75,.31-3.42-.86-3.72-2.61l-.56-3.17c-.31-1.74,.85-3.41,2.6-3.71l66.63-11.75-.29-1.67c-.76-4.32,2.12-8.44,6.44-9.19l26.24-4.63c4.32-.76,8.44,2.12,9.19,6.44l.3,1.67,66.63-11.75c1.74-.31,3.4,.85,3.71,2.59l.56,3.18c.31,1.75-.86,3.41-2.6,3.71m-116.88,120.76v-54.72c0-1.09-.89-1.98-1.99-1.98h-14.09c-6.03,0-11.41,3.59-13.72,9.16-5.06,12.15-7.62,25.04-7.62,38.32,0,3.16,.15,6.31,.45,9.42,.09,1.02,.96,1.79,1.98,1.79h33.01c1.1,0,1.99-.89,1.99-1.99m57.26,1.99h33.01c1.02,0,1.88-.77,1.98-1.79,.29-3.11,.45-6.26,.45-9.42,0-13.28-2.56-26.17-7.62-38.32-2.31-5.57-7.69-9.16-13.72-9.16h-14.09c-1.09,0-1.98,.89-1.98,1.98v54.72c0,1.1,.89,1.99,1.98,1.99m-14.74,34.29v-90.99c0-1.09-.89-1.98-1.98-1.98h-25.8c-1.09,0-1.98,.89-1.98,1.98v90.99c0,1.09,.89,1.98,1.98,1.98h25.8c1.09,0,1.98-.89,1.98-1.98"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 283.465 283.465" enable-background="new 0 0 283.465 283.465" xml:space="preserve">
<g>
<path fill="#FFFFFF" d="M233.576,58.371l-0.56-3.182c-0.308-1.742-1.969-2.906-3.711-2.598L162.67,64.34l-0.296-1.675
c-0.762-4.317-4.878-7.199-9.195-6.438l-26.24,4.627c-4.317,0.761-7.2,4.878-6.438,9.195l0.295,1.674l-66.635,11.75
c-1.742,0.307-2.905,1.969-2.598,3.711l0.559,3.173c0.308,1.747,1.974,2.914,3.722,2.606l81.07-14.295v34.719H98.018
c-11.198,0-21.209,6.685-25.5,17.025c-5.704,13.705-8.596,28.243-8.596,43.21c0,14.969,2.892,29.507,8.594,43.203
c4.293,10.348,14.303,17.034,25.502,17.034h87.429c11.2,0,21.21-6.687,25.498-17.027c5.706-13.701,8.598-28.239,8.598-43.21
c0-14.969-2.892-29.507-8.594-43.202c-4.293-10.347-14.302-17.033-25.502-17.033h-38.895V76.969l84.427-14.887
C232.721,61.775,233.884,60.113,233.576,58.371 M112.111,184.829H79.098c-1.019,0-1.883-0.77-1.978-1.785
c-0.292-3.116-0.443-6.258-0.443-9.422c0-13.276,2.563-26.165,7.621-38.314c2.31-5.568,7.696-9.166,13.72-9.166h14.093
c1.096,0,1.984,0.889,1.984,1.984v54.719C114.095,183.941,113.207,184.829,112.111,184.829 M169.37,182.845v-54.719
c0-1.095,0.889-1.984,1.985-1.984h14.092c6.025,0,11.411,3.598,13.721,9.166c5.058,12.149,7.62,25.038,7.62,38.314
c0,3.164-0.15,6.306-0.442,9.422c-0.095,1.015-0.959,1.785-1.979,1.785h-33.012C170.259,184.829,169.37,183.941,169.37,182.845
M154.63,221.103h-25.795c-1.096,0-1.984-0.889-1.984-1.985v-90.992c0-1.095,0.888-1.984,1.984-1.984h25.795
c1.097,0,1.984,0.889,1.984,1.984v90.992C156.614,220.214,155.727,221.103,154.63,221.103 M283.465,7.37L283.465,7.37
c0-4.07-3.3-7.37-7.37-7.37H7.37C3.3,0,0,3.3,0,7.37c0,4.071,3.3,7.371,7.37,7.371h268.725
C280.165,14.741,283.465,11.441,283.465,7.37 M283.465,276.095L283.465,276.095c0-4.07-3.3-7.37-7.37-7.37H7.37
c-4.07,0-7.37,3.3-7.37,7.37c0,4.07,3.3,7.37,7.37,7.37h268.725C280.165,283.465,283.465,280.165,283.465,276.095"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
symbole_metro_RVB.svg

View File

@@ -0,0 +1 @@
symbole_metro_support_fonce_RVB.svg

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46 283.46"><g id="Layer_1-2"><g><path d="M141.73,0C63.45,0,0,63.46,0,141.73s63.45,141.73,141.73,141.73,141.73-63.46,141.73-141.73S220.01,0,141.73,0m0,265.04c-68.1,0-123.31-55.21-123.31-123.31S73.63,18.43,141.73,18.43s123.3,55.2,123.3,123.3-55.2,123.31-123.3,123.31m70.51-59.89c0,6.74-5.32,10.63-11.69,10.63-5.68,0-12.05-3.89-12.05-10.63V113.03h-.35l-35.08,71.57c-2.48,4.96-5.66,7.79-11.34,7.79s-8.86-2.84-11.34-7.79l-35.08-71.57h-.35v92.13c0,6.74-6.38,10.63-12.05,10.63-6.38,0-11.69-3.89-11.69-10.63V81.5c0-7.09,3.9-14.53,15.59-14.53,8.86,0,12.4,3.9,16.3,11.69l38.62,79.72h.35l38.27-79.72c3.89-7.79,7.44-11.69,16.29-11.69,11.69,0,15.59,7.44,15.59,14.53v123.66"/><path d="M141.73,0C63.45,0,0,63.46,0,141.73s63.45,141.73,141.73,141.73,141.73-63.46,141.73-141.73S220.01,0,141.73,0m0,265.04c-68.1,0-123.31-55.21-123.31-123.31S73.63,18.43,141.73,18.43s123.3,55.2,123.3,123.3-55.2,123.31-123.3,123.31m70.51-59.89c0,6.74-5.32,10.63-11.69,10.63-5.68,0-12.05-3.89-12.05-10.63V113.03h-.35l-35.08,71.57c-2.48,4.96-5.66,7.79-11.34,7.79s-8.86-2.84-11.34-7.79l-35.08-71.57h-.35v92.13c0,6.74-6.38,10.63-12.05,10.63-6.38,0-11.69-3.89-11.69-10.63V81.5c0-7.09,3.9-14.53,15.59-14.53,8.86,0,12.4,3.9,16.3,11.69l38.62,79.72h.35l38.27-79.72c3.89-7.79,7.44-11.69,16.29-11.69,11.69,0,15.59,7.44,15.59,14.53v123.66"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 283.465 283.465" enable-background="new 0 0 283.465 283.465" xml:space="preserve">
<g>
<path fill="#FFFFFF" d="M141.733,0.001C63.456,0.001,0,63.456,0,141.733s63.456,141.733,141.733,141.733
c78.276,0,141.732-63.456,141.732-141.733S220.009,0.001,141.733,0.001 M141.733,268.725c-70.136,0-126.992-56.856-126.992-126.992
S71.597,14.741,141.733,14.741c70.135,0,126.992,56.856,126.992,126.992S211.868,268.725,141.733,268.725 M141.721,192.325
c-4.736,0-7.4-2.32-9.472-6.38l-38.104-75.984h-0.354l0.001,96.712c0,5.721-5.42,9.035-10.24,9.035
c-5.421,0-9.939-3.315-9.939-9.036V78.7c0-5.669,3.261-11.622,13.045-11.622c7.413,0,10.57,3.118,13.639,9.355l41.259,87.057h0.354
l41.235-87.057c3.069-6.237,6.225-9.355,13.639-9.355c9.784,0,13.045,5.953,13.045,11.622v127.972c0,5.721-4.517,9.036-9.939,9.036
c-4.82,0-10.24-3.314-10.24-9.035l0.001-96.712h-0.355l-38.103,75.984C149.121,190.005,146.457,192.325,141.721,192.325"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46 283.46"><g id="Layer_1-2"><g><g><path d="M171.5,181.43c0-4.26-2.84-8.86-7.79-8.86h-26.94v-24.45h22.32c4.96,0,7.8-4.26,7.8-8.15,0-4.25-2.84-8.15-7.8-8.15h-22.32v-22.32h24.81c4.96,0,7.79-4.96,7.79-8.85,0-4.26-2.83-8.86-7.79-8.86h-36.15c-4.96,0-7.79,3.89-7.79,8.15v82.92c0,4.96,4.96,7.44,9.56,7.44h36.5c4.96,0,7.79-4.96,7.79-8.85"/><path d="M226.42,147.06c9.22-4.62,17.02-12.76,17.02-27.29,0-19.13-14.19-27.99-27.99-27.99h-26.58c-4.96,0-7.79,3.89-7.79,8.15v83.27c0,4.96,4.96,7.43,9.55,7.43,5.32,0,9.57-2.48,9.57-7.43v-32.24h8.15l15.94,35.78c1.42,2.84,3.91,3.89,6.74,3.89,5.66,0,13.82-5.31,10.63-11.68l-15.24-31.89Zm-20.55-10.28h-5.67v-28.7h6.72c8.86,0,16.65,3.9,16.65,13.82,0,12.05-10.97,14.88-17.71,14.88Z"/><path d="M89.29,147.06c9.22-4.62,17.01-12.76,17.01-27.29,0-19.13-14.17-27.99-27.99-27.99h-26.57c-4.96,0-7.8,3.89-7.8,8.15v83.27c0,4.96,4.96,7.43,9.56,7.43,5.31,0,9.56-2.48,9.56-7.43v-32.24h8.15l15.95,35.78c1.42,2.84,3.91,3.89,6.74,3.89,5.67,0,13.81-5.31,10.63-11.68l-15.24-31.89Zm-20.55-10.28h-5.68v-28.7h6.74c8.85,0,16.65,3.9,16.65,13.82,0,12.05-10.98,14.88-17.72,14.88Z"/><path d="M219.69,0H63.78C28.55,0,0,28.56,0,63.78V219.68c0,35.22,28.55,63.78,63.78,63.78H219.69c35.22,0,63.78-28.56,63.78-63.78V63.78c0-35.22-28.55-63.78-63.78-63.78Zm45.35,219.68c0,25.05-20.31,45.35-45.35,45.35H63.78c-25.05,0-45.35-20.31-45.35-45.35V63.78c0-25.05,20.31-45.36,45.35-45.36H219.69c25.05,0,45.35,20.31,45.35,45.36V219.68Z"/></g><g><path d="M171.5,181.43c0-4.26-2.84-8.86-7.79-8.86h-26.94v-24.45h22.32c4.96,0,7.8-4.26,7.8-8.15,0-4.25-2.84-8.15-7.8-8.15h-22.32v-22.32h24.81c4.96,0,7.79-4.96,7.79-8.85,0-4.26-2.83-8.86-7.79-8.86h-36.15c-4.96,0-7.79,3.89-7.79,8.15v82.92c0,4.96,4.96,7.44,9.56,7.44h36.5c4.96,0,7.79-4.96,7.79-8.85"/><path d="M226.42,147.06c9.22-4.62,17.02-12.76,17.02-27.29,0-19.13-14.19-27.99-27.99-27.99h-26.58c-4.96,0-7.79,3.89-7.79,8.15v83.27c0,4.96,4.96,7.43,9.55,7.43,5.32,0,9.57-2.48,9.57-7.43v-32.24h8.15l15.94,35.78c1.42,2.84,3.91,3.89,6.74,3.89,5.66,0,13.82-5.31,10.63-11.68l-15.24-31.89Zm-20.55-10.28h-5.67v-28.7h6.72c8.86,0,16.65,3.9,16.65,13.82,0,12.05-10.97,14.88-17.71,14.88Z"/><path d="M89.29,147.06c9.22-4.62,17.01-12.76,17.01-27.29,0-19.13-14.17-27.99-27.99-27.99h-26.57c-4.96,0-7.8,3.89-7.8,8.15v83.27c0,4.96,4.96,7.43,9.56,7.43,5.31,0,9.56-2.48,9.56-7.43v-32.24h8.15l15.95,35.78c1.42,2.84,3.91,3.89,6.74,3.89,5.67,0,13.81-5.31,10.63-11.68l-15.24-31.89Zm-20.55-10.28h-5.68v-28.7h6.74c8.85,0,16.65,3.9,16.65,13.82,0,12.05-10.98,14.88-17.72,14.88Z"/><path d="M219.69,0H63.78C28.55,0,0,28.56,0,63.78V219.68c0,35.22,28.55,63.78,63.78,63.78H219.69c35.22,0,63.78-28.56,63.78-63.78V63.78c0-35.22-28.55-63.78-63.78-63.78Zm45.35,219.68c0,25.05-20.31,45.35-45.35,45.35H63.78c-25.05,0-45.35-20.31-45.35-45.35V63.78c0-25.05,20.31-45.36,45.35-45.36H219.69c25.05,0,45.35,20.31,45.35,45.36V219.68Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 283.465 283.465" enable-background="new 0 0 283.465 283.465" xml:space="preserve">
<g>
<path fill="#FFFFFF" d="M78.31,91.787c12.98,0,26.078,8.359,26.078,27.038c0,12.107-5.367,21.027-15.949,26.511
c-0.58,0.265-1.157,0.503-1.732,0.731l17.031,35.648c0.75,1.501,0.684,2.928-0.203,4.363c-1.556,2.516-5.465,4.563-8.716,4.563
c-2.456,0-4.052-0.903-5.021-2.841l-17.049-38.127H60.703v35.212c0,2.871-4.102,5.675-7.654,5.675
c-4.099,0-7.656-2.804-7.656-5.675V99.923c0-4.252,2.269-8.152,8.849-8.15L78.31,91.787z M45.393,184.885V99.923
c0-4.252,2.269-8.152,8.849-8.15 M68.742,136.917c9.056,0,19.63-4.399,19.63-16.795c0-9.705-7.114-15.733-18.567-15.733h-9.102
v32.528H68.742z"/>
<path fill="#FFFFFF" d="M215.438,91.787c12.98,0,26.078,8.359,26.078,27.038c0,12.107-5.367,21.027-15.949,26.511
c-0.58,0.265-1.157,0.503-1.732,0.731l17.031,35.648c0.75,1.501,0.684,2.928-0.203,4.363c-1.556,2.516-5.465,4.563-8.716,4.563
c-2.456,0-4.052-0.903-5.021-2.841l-17.049-38.127h-12.046v35.212c0,2.871-4.102,5.675-7.654,5.675
c-4.099,0-7.656-2.804-7.656-5.675V99.923c0-4.252,2.269-8.152,8.849-8.15L215.438,91.787z M205.87,136.917
c9.056,0,19.63-4.399,19.63-16.795c0-9.705-7.114-15.733-18.567-15.733h-9.102v32.528H205.87z"/>
<path fill="#FFFFFF" d="M163.987,190.277c3.786,0,5.881-4.188,5.881-7.084c0-2.942-1.825-7.087-5.881-7.088h-29.33v-29.623h24.724
c4.056,0,5.881-3.813,5.881-6.52c0-3.14-1.84-6.519-5.881-6.519h-24.724v-27.496h27.203c3.838,0,5.883-4.115,5.883-7.087
c0-2.942-1.825-7.086-5.883-7.086h-33.632c-6.58-0.002-8.848,3.898-8.848,8.15v82.913c0,5.455,4.577,7.435,7.653,7.435
L163.987,190.277z"/>
<path fill="#FFFFFF" d="M219.685,283.465H63.78C28.556,283.465,0,254.91,0,219.685V63.78C0,28.556,28.556,0,63.78,0h155.905
c35.225,0,63.78,28.556,63.78,63.78v155.905C283.465,254.91,254.91,283.465,219.685,283.465 M268.725,219.685V63.78
c0-27.084-21.956-49.039-49.04-49.039H63.78c-27.084,0-49.039,21.955-49.039,49.039v155.905c0,27.084,21.955,49.04,49.039,49.04
h155.905C246.769,268.725,268.725,246.769,268.725,219.685"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46 283.46"><path d="M274.25,18.42H9.5A9.39,9.39,0,0,1,.1,10.58,9.22,9.22,0,0,1,9.21,0H274a9.41,9.41,0,0,1,9.4,7.85,9.22,9.22,0,0,1-9.12,10.57m9.21,255.83h0a9.21,9.21,0,0,0-9.21-9.21H9.5a9.39,9.39,0,0,0-9.4,7.84,9.22,9.22,0,0,0,9.11,10.58h265a9.21,9.21,0,0,0,9.21-9.21" transform="translate(0)"/><path d="M124.42,141.74l2-7.49,4.83-18.14.94,1.34c.18.26.38.51.58.75a10.16,10.16,0,0,0,2.93,2.37,11.7,11.7,0,0,0,1.74.75l10.08,3.78a31.12,31.12,0,0,0,15.36-7.29l-21-7.89S134.51,97.78,131,94.49a7.82,7.82,0,0,0-4.33-2.16,10.55,10.55,0,0,0-2.39-.26h0a15.42,15.42,0,0,0-2.25.31l-.24.05a1.14,1.14,0,0,0-.25.06h0a18.27,18.27,0,0,0-12.67,12.81l-8,30.25a10.19,10.19,0,0,0,0,4.08,10.77,10.77,0,0,0,2.82,5.2l2.53,2.87,4.14,4.71,20.19,21.16-5.59,39.71a6.87,6.87,0,0,0,10.06,6.32,6.7,6.7,0,0,0,2.23-4.36l7-39.54a10.72,10.72,0,0,0,.17-2.71,10.17,10.17,0,0,0-1.76-5.07Z" transform="translate(0)"/><path d="M128.78,87c3.19.68,6.75.88,9.46-.83s3.93-5.12,4.61-8.31.88-6.75-.83-9.47-5.12-3.93-8.31-4.6-6.75-.88-9.47.82-3.92,5.12-4.6,8.32-.88,6.75.82,9.47S125.58,86.32,128.78,87Z" transform="translate(0)"/><path d="M241.83,197.62a37.21,37.21,0,0,0-31.63-35.34,36.43,36.43,0,0,0-18,1.87l-19.67-34.07a46.36,46.36,0,0,0,12.6-16.8,2.87,2.87,0,0,0-1.66-3.83l-4-1.46a2.82,2.82,0,0,0-3.54,1.53,36.76,36.76,0,0,1-34.05,21.71,2.86,2.86,0,0,0-2.95,2.57l-.37,4.25a2.86,2.86,0,0,0,2.76,3.12,46.69,46.69,0,0,0,20.41-4.23l5.41,9.37-16.63,26.55L144.94,204A7.81,7.81,0,0,0,148,201l26.35-42.09,6.75,11.68a37,37,0,1,0,60.73,27.08Zm-40,28.1a27,27,0,0,1-15.58-46.33l13.36,23.25a4.27,4.27,0,0,0,5.83,1.56l3.7-2.13a4.27,4.27,0,0,0,1.56-5.83L197.32,173a27,27,0,1,1,4.47,52.68Z" transform="translate(0)"/><path d="M116,192a36.89,36.89,0,0,0-9-18l7.09-9.36-9-9.4-8.22,10.91a37,37,0,0,0-25.29-3.38,37,37,0,1,0,44.57,42h4.18l1.8-12.8Zm-15.23-9.79A26.7,26.7,0,0,1,105.7,192H93.36Zm-21.1,43.71a27,27,0,0,1-7.42-53,26.54,26.54,0,0,1,18.47,1.36l-16.2,21.4a5.68,5.68,0,0,0,4.53,9.12h27A27,27,0,0,1,79.67,225.89Z" transform="translate(0)"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
symbole_transilien_RVB.svg

View File

@@ -0,0 +1 @@
symbole_transilien_support_fonce_RVB.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 28.346 28.346" enable-background="new 0 0 28.346 28.346" xml:space="preserve">
<g>
<path fill="#FFFFFF" d="M10.615,12.933c0.029-0.019,0.057-0.04,0.082-0.063c0.247-0.201,0.479-0.508,0.647-0.986
c0.236-0.668,0.402-1.265,0.447-2.001c0.064-0.905-0.309-2.313-0.555-2.969l-0.087-0.231c-0.053-0.146-0.256-0.461-0.616-0.836
c-0.309-0.323-0.607-0.346-1.06-0.346H7.634c-0.452,0-0.749,0.023-1.059,0.346C6.214,6.221,6.011,6.536,5.959,6.683L5.873,6.915
C5.625,7.57,5.253,8.978,5.315,9.886c0.045,0.733,0.212,1.33,0.448,2c0.168,0.478,0.4,0.784,0.648,0.985
c0.026,0.022,0.053,0.044,0.082,0.062l-1.339,1.488c-0.053,0.061-0.082,0.139-0.082,0.22c0,0.08,0.029,0.158,0.082,0.219
c0.025,0.028,0.055,0.051,0.088,0.066c0.035,0.016,0.071,0.024,0.108,0.024c0.038,0,0.074-0.008,0.109-0.024
c0.033-0.015,0.064-0.038,0.087-0.066l0.463-0.514h5.09l0.461,0.514c0.025,0.028,0.055,0.051,0.089,0.066
c0.034,0.016,0.07,0.024,0.108,0.024c0.037,0,0.074-0.008,0.108-0.024c0.034-0.015,0.064-0.038,0.088-0.066
c0.053-0.061,0.082-0.139,0.082-0.219c0-0.081-0.029-0.159-0.082-0.22L10.615,12.933z M10.166,12.39
c-0.091,0-0.18-0.027-0.255-0.077c-0.075-0.051-0.134-0.123-0.169-0.206c-0.035-0.085-0.044-0.177-0.026-0.266
c0.017-0.089,0.062-0.171,0.126-0.235c0.064-0.065,0.146-0.108,0.235-0.126c0.089-0.018,0.182-0.009,0.265,0.026
c0.084,0.034,0.156,0.094,0.206,0.169c0.051,0.076,0.078,0.165,0.078,0.255c0,0.122-0.049,0.239-0.135,0.325
C10.405,12.342,10.288,12.39,10.166,12.39 M6.004,9.443c-0.281,0,0.162-2.466,0.362-2.466h4.325c0.22,0,0.731,2.466,0.391,2.466
H6.004z M6.473,11.93c0-0.09,0.027-0.179,0.077-0.256c0.051-0.075,0.123-0.134,0.207-0.168c0.083-0.035,0.176-0.045,0.266-0.027
c0.089,0.018,0.17,0.061,0.235,0.126c0.064,0.064,0.108,0.146,0.126,0.235c0.018,0.09,0.009,0.182-0.026,0.267
c-0.035,0.083-0.094,0.155-0.17,0.206c-0.075,0.05-0.164,0.077-0.255,0.077c-0.061,0-0.121-0.012-0.176-0.034
c-0.056-0.024-0.108-0.058-0.149-0.1c-0.044-0.043-0.077-0.094-0.1-0.149C6.484,12.05,6.473,11.99,6.473,11.93 M6.566,13.726
l0.492-0.547c0.206,0.042,0.415,0.06,0.624,0.055h1.744c0.209,0.005,0.418-0.013,0.622-0.055l0.495,0.547H6.566z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" d="M21.942,7.531c0.184,0.184,0.184,0.483,0,0.668L7.769,22.371
c-0.184,0.186-0.484,0.186-0.668,0c-0.185-0.184-0.185-0.483,0-0.668L21.274,7.531C21.458,7.346,21.758,7.346,21.942,7.531"/>
<path fill="#FFFFFF" d="M21.968,28.346H6.377C2.855,28.346,0,25.491,0,21.968V6.378c0-3.523,2.856-6.379,6.378-6.379h15.591
c3.523,0,6.378,2.856,6.378,6.379v15.59C28.346,25.491,25.491,28.346,21.968,28.346 M26.872,21.968V6.378
c0-2.709-2.196-4.904-4.904-4.904H6.377c-2.708,0-4.903,2.195-4.903,4.904v15.59c0,2.708,2.195,4.904,4.903,4.904h15.591
C24.676,26.872,26.872,24.676,26.872,21.968"/>
<path fill="#FFFFFF" d="M16.165,18.045c0.631,0,1.267,0.407,1.267,1.315c0,0.588-0.26,1.022-0.774,1.288
c-0.029,0.013-0.057,0.025-0.085,0.036l0.828,1.732c0.036,0.073,0.033,0.143-0.01,0.213c-0.075,0.122-0.265,0.221-0.423,0.221
c-0.12,0-0.197-0.043-0.245-0.138l-0.828-1.853h-0.586v1.712c0,0.139-0.199,0.275-0.372,0.275c-0.199,0-0.372-0.136-0.372-0.275
v-4.13c0-0.207,0.111-0.396,0.43-0.396H16.165z M14.565,22.571v-4.13c0-0.207,0.111-0.396,0.43-0.396 M15.7,20.239
c0.44,0,0.954-0.214,0.954-0.816c0-0.472-0.346-0.765-0.902-0.765h-0.443v1.581H15.7z"/>
<path fill="#FFFFFF" d="M22.83,18.045c0.631,0,1.267,0.407,1.267,1.315c0,0.588-0.26,1.022-0.775,1.288
c-0.028,0.013-0.056,0.025-0.084,0.036l0.828,1.732c0.036,0.073,0.033,0.143-0.01,0.213c-0.076,0.122-0.266,0.221-0.424,0.221
c-0.119,0-0.197-0.043-0.244-0.138l-0.829-1.853h-0.585v1.712c0,0.139-0.199,0.275-0.372,0.275c-0.199,0-0.372-0.136-0.372-0.275
v-4.13c0-0.207,0.11-0.396,0.43-0.396H22.83z M22.365,20.239c0.44,0,0.954-0.214,0.954-0.816c0-0.472-0.346-0.765-0.903-0.765
h-0.442v1.581H22.365z"/>
<path fill="#FFFFFF" d="M20.329,22.832c0.184,0,0.286-0.203,0.286-0.344c0-0.143-0.089-0.345-0.286-0.345h-1.425v-1.44h1.201
c0.198,0,0.286-0.185,0.286-0.317c0-0.152-0.089-0.316-0.286-0.316h-1.201v-1.337h1.322c0.186,0,0.286-0.2,0.286-0.344
c0-0.143-0.089-0.345-0.286-0.345h-1.635c-0.32,0-0.43,0.19-0.43,0.396v4.03c0,0.266,0.222,0.362,0.372,0.362H20.329z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46 283.46"><g id="Layer_1-2"><g><path d="M274.25,18.42H9.21C4.12,18.42,0,14.3,0,9.21S4.12,0,9.21,0H274.25c5.09,0,9.21,4.12,9.21,9.21s-4.12,9.21-9.21,9.21m9.21,255.83h0c0-5.09-4.12-9.22-9.21-9.22H9.21c-5.09,0-9.21,4.13-9.21,9.22s4.12,9.21,9.21,9.21H274.25c5.09,0,9.21-4.12,9.21-9.21m-32.32-38.41c2.19,0,3.97-1.78,3.97-3.97v-3.97c0-2.19-1.78-3.96-3.97-3.96H28.35v11.91H251.14M28.35,170.78h41.25c1.09,0,1.98-.89,1.98-1.98v-54.85c0-1.09-.89-1.98-1.98-1.98H28.35v-12.76H92.72l25.23-20.74-31.74-26.08c-1.37-1.12-1.57-3.15-.44-4.52l2.04-2.49c1.13-1.37,3.15-1.56,4.52-.44l36.14,29.69c2.41,1.98,2.42,5.68,0,7.66l-20.58,16.91h98.03c7.78,0,15.18,3.29,20.43,9.03,15.45,16.89,25.27,37.77,28.41,60.39,1,7.19-.09,14.31-3.16,20.56-7.09,14.44-21.47,23.41-37.55,23.41H28.35v-41.81Zm188.48-54.06c-2.8-3.05-6.77-4.75-10.91-4.75h-34.57c-1.09,0-1.98,.89-1.98,1.98v54.85c0,1.09,.89,1.98,1.98,1.98h59.11c6.94,0,11.07-5.46,9.31-12.17-4.1-15.56-11.91-29.87-22.94-41.89m-62.2,83.12c1.09,0,1.98-.89,1.98-1.98V113.95c0-1.09-.89-1.98-1.98-1.98h-25.79c-1.1,0-1.99,.89-1.99,1.98v83.91c0,1.09,.89,1.98,1.99,1.98h25.79M86.32,111.97c-1.09,0-1.98,.89-1.98,1.98v83.91c0,1.09,.89,1.98,1.98,1.98h25.79c1.1,0,1.99-.89,1.99-1.98V113.95c0-1.09-.89-1.98-1.99-1.98h-25.79"/><path d="M274.25,18.42H9.21C4.12,18.42,0,14.3,0,9.21S4.12,0,9.21,0H274.25c5.09,0,9.21,4.12,9.21,9.21s-4.12,9.21-9.21,9.21m9.21,255.83h0c0-5.09-4.12-9.22-9.21-9.22H9.21c-5.09,0-9.21,4.13-9.21,9.22s4.12,9.21,9.21,9.21H274.25c5.09,0,9.21-4.12,9.21-9.21m-32.32-38.41c2.19,0,3.97-1.78,3.97-3.97v-3.97c0-2.19-1.78-3.96-3.97-3.96H28.35v11.91H251.14M28.35,170.78h41.25c1.09,0,1.98-.89,1.98-1.98v-54.85c0-1.09-.89-1.98-1.98-1.98H28.35v-12.76H92.72l25.23-20.74-31.74-26.08c-1.37-1.12-1.57-3.15-.44-4.52l2.04-2.49c1.13-1.37,3.15-1.56,4.52-.44l36.14,29.69c2.41,1.98,2.42,5.68,0,7.66l-20.58,16.91h98.03c7.78,0,15.18,3.29,20.43,9.03,15.45,16.89,25.27,37.77,28.41,60.39,1,7.19-.09,14.31-3.16,20.56-7.09,14.44-21.47,23.41-37.55,23.41H28.35v-41.81Zm188.48-54.06c-2.8-3.05-6.77-4.75-10.91-4.75h-34.57c-1.09,0-1.98,.89-1.98,1.98v54.85c0,1.09,.89,1.98,1.98,1.98h59.11c6.94,0,11.07-5.46,9.31-12.17-4.1-15.56-11.91-29.87-22.94-41.89m-62.2,83.12c1.09,0,1.98-.89,1.98-1.98V113.95c0-1.09-.89-1.98-1.98-1.98h-25.79c-1.1,0-1.99,.89-1.99,1.98v83.91c0,1.09,.89,1.98,1.99,1.98h25.79M86.32,111.97c-1.09,0-1.98,.89-1.98,1.98v83.91c0,1.09,.89,1.98,1.98,1.98h25.79c1.1,0,1.99-.89,1.99-1.98V113.95c0-1.09-.89-1.98-1.99-1.98h-25.79"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 283.465 283.465" enable-background="new 0 0 283.465 283.465" xml:space="preserve">
<g>
<path fill="#FFFFFF" d="M276.095,14.741H7.37c-4.07,0-7.37-3.3-7.37-7.37c0-4.07,3.3-7.37,7.37-7.37h268.725
c4.07,0,7.37,3.3,7.37,7.37C283.465,11.441,280.165,14.741,276.095,14.741"/>
<path fill="#FFFFFF" d="M276.095,283.465H7.37c-4.07,0-7.37-3.3-7.37-7.37c0-4.07,3.3-7.37,7.37-7.37h268.725
c4.07,0,7.37,3.3,7.37,7.37C283.465,280.165,280.165,283.465,276.095,283.465"/>
<path fill="#FFFFFF" d="M251.149,235.841H28.346v-11.904h222.803c2.191,0,3.969,1.776,3.969,3.967v3.969
C255.118,234.066,253.34,235.841,251.149,235.841"/>
<path fill="#FFFFFF" d="M28.347,212.6h185.7c16.075,0,30.461-8.971,37.546-23.412c3.07-6.257,4.164-13.365,3.167-20.561
c-3.137-22.618-12.96-43.499-28.406-60.389c-5.247-5.738-12.654-9.024-20.43-9.024h-98.025l20.574-16.909
c2.415-1.984,2.414-5.681-0.001-7.665L92.332,44.949c-1.371-1.127-3.395-0.929-4.522,0.442l-2.04,2.483
c-1.126,1.371-0.928,3.395,0.443,4.522l31.743,26.077L92.719,99.214H28.347v12.756h41.243c1.096,0,1.985,0.889,1.985,1.984v54.85
c0,1.097-0.889,1.985-1.985,1.985H28.347V212.6z M216.829,116.724c11.03,12.022,18.841,26.331,22.941,41.892
c1.769,6.709-2.362,12.173-9.302,12.173h-59.113c-1.096,0-1.984-0.888-1.984-1.985v-54.85c0-1.095,0.888-1.984,1.984-1.984h34.569
C210.061,111.97,214.031,113.675,216.829,116.724 M154.63,199.844h-25.794c-1.096,0-1.985-0.889-1.985-1.984v-83.906
c0-1.095,0.889-1.984,1.985-1.984h25.794c1.096,0,1.985,0.889,1.985,1.984v83.906C156.615,198.955,155.725,199.844,154.63,199.844
M86.314,111.97h25.796c1.096,0,1.984,0.889,1.984,1.984v83.906c0,1.095-0.888,1.984-1.984,1.984H86.314
c-1.096,0-1.984-0.889-1.984-1.984v-83.906C84.33,112.859,85.218,111.97,86.314,111.97"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46 283.46"><g id="Layer_1-2"><g><path d="M219.68,283.46H63.78c-35.22,0-63.78-28.55-63.78-63.78V63.78C0,28.55,28.56,0,63.78,0H219.68c35.22,0,63.78,28.55,63.78,63.78V219.69c0,35.22-28.56,63.78-63.78,63.78m45.36-63.78V63.78c0-25.05-20.31-45.35-45.36-45.35H63.78c-25.05,0-45.35,20.31-45.35,45.35V219.69c0,25.05,20.31,45.35,45.35,45.35H219.68c25.05,0,45.36-20.31,45.36-45.35m-81.17-15.09c.62-.42,1.22-.86,1.66-1.29,5.09-4.12,9.86-10.45,13.31-20.24,4.83-13.74,8.24-26.02,9.18-41.12,1.31-18.6-6.35-47.54-11.43-61.04l-1.77-4.75c-1.09-3.04-5.26-9.47-12.65-17.18-6.35-6.62-12.47-7.11-21.77-7.11h-37.76c-9.3,0-15.4,.49-21.78,7.11-7.39,7.69-11.56,14.15-12.65,17.18l-1.78,4.78c-5.08,13.48-12.73,42.42-11.44,61.04,.94,15.06,4.36,27.32,9.21,41.1,3.45,9.82,8.19,16.12,13.29,20.24,.48,.42,1.06,.85,1.69,1.27l-27.51,30.58c-2.24,2.5-2.24,6.53,0,9.01,2.24,2.47,5.85,2.47,8.07,0l9.5-10.54h104.53l9.48,10.54c2.22,2.47,5.85,2.47,8.07,0,2.24-2.48,2.24-6.51,0-9.01l-27.48-30.56Zm-9.24-11.15c-5.21,0-9.45-4.22-9.45-9.45s4.24-9.42,9.45-9.42,9.46,4.22,9.46,9.42-4.22,9.45-9.46,9.45m-85.47-60.55c-5.77,0,3.29-50.66,7.43-50.66h88.85c4.49,0,15.02,50.66,7.96,50.66H89.16Zm9.62,51.1c0-5.2,4.23-9.42,9.45-9.42s9.44,4.22,9.44,9.42-4.21,9.45-9.44,9.45-9.45-4.22-9.45-9.45m1.92,36.9l10.13-11.25c4.94,1.14,9.48,1.14,12.79,1.14h35.82c3.31,0,7.86,0,12.79-1.14l10.14,11.25H100.7Z"/><path d="M219.68,283.46H63.78c-35.22,0-63.78-28.55-63.78-63.78V63.78C0,28.55,28.56,0,63.78,0H219.68c35.22,0,63.78,28.55,63.78,63.78V219.69c0,35.22-28.56,63.78-63.78,63.78m45.36-63.78V63.78c0-25.05-20.31-45.35-45.36-45.35H63.78c-25.05,0-45.35,20.31-45.35,45.35V219.69c0,25.05,20.31,45.35,45.35,45.35H219.68c25.05,0,45.36-20.31,45.36-45.35m-81.17-15.09c.62-.42,1.22-.86,1.66-1.29,5.09-4.12,9.86-10.45,13.31-20.24,4.83-13.74,8.24-26.02,9.18-41.12,1.31-18.6-6.35-47.54-11.43-61.04l-1.77-4.75c-1.09-3.04-5.26-9.47-12.65-17.18-6.35-6.62-12.47-7.11-21.77-7.11h-37.76c-9.3,0-15.4,.49-21.78,7.11-7.39,7.69-11.56,14.15-12.65,17.18l-1.78,4.78c-5.08,13.48-12.73,42.42-11.44,61.04,.94,15.06,4.36,27.32,9.21,41.1,3.45,9.82,8.19,16.12,13.29,20.24,.48,.42,1.06,.85,1.69,1.27l-27.51,30.58c-2.24,2.5-2.24,6.53,0,9.01,2.24,2.47,5.85,2.47,8.07,0l9.5-10.54h104.53l9.48,10.54c2.22,2.47,5.85,2.47,8.07,0,2.24-2.48,2.24-6.51,0-9.01l-27.48-30.56Zm-9.24-11.15c-5.21,0-9.45-4.22-9.45-9.45s4.24-9.42,9.45-9.42,9.46,4.22,9.46,9.42-4.22,9.45-9.46,9.45m-85.47-60.55c-5.77,0,3.29-50.66,7.43-50.66h88.85c4.49,0,15.02,50.66,7.96,50.66H89.16Zm9.62,51.1c0-5.2,4.23-9.42,9.45-9.42s9.44,4.22,9.44,9.42-4.21,9.45-9.44,9.45-9.45-4.22-9.45-9.45m1.92,36.9l10.13-11.25c4.94,1.14,9.48,1.14,12.79,1.14h35.82c3.31,0,7.86,0,12.79-1.14l10.14,11.25H100.7Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 283.465 283.465" enable-background="new 0 0 283.465 283.465" xml:space="preserve">
<g>
<path fill="#FFFFFF" d="M183.87,204.599c0.622-0.424,1.223-0.858,1.666-1.293c5.097-4.122,9.857-10.449,13.313-20.242
c4.827-13.737,8.238-26.025,9.183-41.121c1.306-18.605-6.354-47.538-11.433-61.04l-1.767-4.757
c-1.092-3.031-5.257-9.47-12.655-17.179c-6.353-6.618-12.468-7.108-21.768-7.108h-37.757c-9.3,0-15.405,0.49-21.777,7.108
c-7.397,7.687-11.563,14.148-12.646,17.179l-1.776,4.779c-5.079,13.48-12.734,42.413-11.441,61.04
c0.94,15.063,4.36,27.318,9.209,41.099c3.447,9.814,8.194,16.12,13.295,20.242c0.483,0.423,1.057,0.847,1.693,1.271l-27.504,30.581
c-2.237,2.496-2.237,6.528,0,9.002c2.233,2.472,5.849,2.472,8.069,0l9.499-10.539h104.528l9.478,10.539
c2.215,2.472,5.845,2.472,8.068,0c2.237-2.474,2.237-6.506,0-9.002L183.87,204.599z M174.639,193.447
c-5.216,0-9.456-4.223-9.456-9.447c0-5.203,4.24-9.425,9.456-9.425c5.231,0,9.454,4.222,9.454,9.425
C184.093,189.224,179.87,193.447,174.639,193.447 M89.161,132.897c-5.771,0,3.294-50.657,7.432-50.657h88.847
c4.492,0,15.028,50.657,7.969,50.657H89.161z M98.782,184c0-5.203,4.226-9.425,9.448-9.425c5.231,0,9.439,4.222,9.439,9.425
c0,5.224-4.208,9.447-9.439,9.447C103.008,193.447,98.782,189.224,98.782,184 M100.697,220.898l10.127-11.253
c4.935,1.137,9.483,1.137,12.798,1.137h35.817c3.307,0,7.854,0,12.789-1.137l10.132,11.253H100.697z"/>
<path fill="#FFFFFF" d="M219.685,283.465H63.78C28.556,283.465,0,254.91,0,219.685V63.78C0,28.556,28.556,0,63.78,0h155.905
c35.225,0,63.78,28.556,63.78,63.78v155.905C283.465,254.91,254.91,283.465,219.685,283.465 M268.725,219.685V63.78
c0-27.084-21.956-49.039-49.04-49.039H63.78c-27.084,0-49.039,21.955-49.039,49.039v155.905c0,27.084,21.955,49.04,49.039,49.04
h155.905C246.769,268.725,268.725,246.769,268.725,219.685"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,21 @@
.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;
}

65
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { Component } from 'solid-js';
import { MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction, CustomEvent, IVisibilityActionRequest } from 'matrix-widget-api';
import { HopeProvider } from "@hope-ui/solid";
import { BusinessDataProvider } from './businessData';
import { SearchProvider } from './search';
import { NextPassagesDisplay } from './nextPassagesDisplay';
import { StopsManager } from './stopsManager';
import styles from './App.module.css';
function parseFragment() {
const fragmentString = (window.location.hash || "?");
return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0)));
}
const App: Component = () => {
console.log('App: New');
const qs = parseFragment();
const widgetId = qs.get('widgetId');
const userId = qs.get('userId');
console.log("App: widgetId:" + widgetId);
console.log("App: userId:" + userId);
const api = new WidgetApi(widgetId);
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
api.start();
api.on("ready", function() {
console.log("App: widget API is READY !!!!");
});
// Seems to don´t be used...
api.on(`action:${WidgetApiToWidgetAction.UpdateVisibility}`, (ev: CustomEvent<IVisibilityActionRequest>) => {
console.log("App: Visibility change");
ev.preventDefault(); // we're handling it, so stop the widget API from doing something.
console.log("App: ", ev.detail); // custom handling here
/* api.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); */
api.transport.reply(ev.detail, {});
});
return (
<BusinessDataProvider>
<SearchProvider>
<HopeProvider>
<div class={styles.App} data-panelsnap-id="1">
<div class={styles.panel}>
<StopsManager />
</div>
<div class={styles.panel}>
<NextPassagesDisplay />
</div>
</div>
</HopeProvider>
</SearchProvider>
</BusinessDataProvider>
);
};
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,79 @@
import { createContext, createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Passages, Stops } from './types';
interface Store {
passages: () => Passages;
getLinePassages?: (lineId: string) => Passages;
addPassages?: (passages) => void;
clearPassages?: () => void;
stops: () => Stops;
addStops?: (stops) => void;
};
export const BusinessDataContext = createContext<Store>();
export function BusinessDataProvider(props: { children: JSX.Element }) {
const [serverUrl, setServerUrl] = createSignal<string>("https://localhost:4443");
const [store, setStore] = createStore({ lines: {}, passages: {}, stops: {} });
async function getLine(lineId: number) {
let line = store.lines[lineId];
if (line === undefined) {
console.log(`${lineId} not found... fetch it from backend.`);
const data = await fetch(`${serverUrl()}/line/${lineId}`, {
headers: { 'Content-Type': 'application/json' }
});
line = await data.json();
setStore('lines', lineId, line);
}
return line;
}
const passages = () => {
return store.passages;
};
const getLinePassages = (lineId: string) => {
return store.passages[lineId];
};
const addPassages = (passages) => {
setStore((s) => {
// console.log("s=", s);
setStore('passages', passages);
// console.log("s=", s);
});
}
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));
for (const lineId of Object.keys(s.passages)) {
console.log("lineId=", lineId);
setStore('passages', lineId, undefined);
}
console.log("s=", s);
});
// setStore('passages', undefined);
// setStore('passages', {});
// }
console.log("passages=", store.passages);
}
return (
<BusinessDataContext.Provider value={{ getLine, passages, getLinePassages, addPassages, clearPassages, serverUrl }}>
{props.children}
</BusinessDataContext.Provider>
);
}

28
frontend/src/index.css Normal file
View File

@@ -0,0 +1,28 @@
@font-face {
font-family: IDFVoyageur-regular;
src: url(/public/fonts/IDFVoyageur-Regular.otf)
}
@font-face {
font-family: IDFVoyageur-bold;
src: url(/public/fonts/IDFVoyageur-Bold.otf);
}
@font-face {
font-family: IDFVoyageur-medium;
src: url(/public/fonts/IDFVoyageur-Medium.otf);
}
body {
aspect-ratio: 16/9;
width: 100vw;
margin: 0;
font-family: IDFVoyageur;
}
#root {
height: inherit;
width: inherit;
}

7
frontend/src/index.tsx Normal file
View File

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

View File

@@ -0,0 +1,226 @@
/* 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

@@ -0,0 +1,253 @@
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

@@ -0,0 +1,121 @@
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>
);
}

75
frontend/src/search.tsx Normal file
View File

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

View File

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,224 @@
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>
);
};

43
frontend/src/types.tsx Normal file
View File

@@ -0,0 +1,43 @@
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,
DISRUPTED,
VERY_DISRUPTED,
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 interface Stops { };
export interface Stop {
id: number,
name: string,
town: string,
lat: number,
lon: number,
lines: Array<string>
};

106
frontend/src/utils.tsx Normal file
View File

@@ -0,0 +1,106 @@
import { getTransportModeSrc } from './types';
export const TransportModeWeights = {
bus: 1,
tram: 2,
val: 3,
funicular: 4,
metro: 5,
rer: 6,
transilien: 7,
ter: 8,
};
export function renderLineTransportMode(line): JSX.Element {
return <img src={getTransportModeSrc(line.transportMode)} />
}
function renderBusLinePicto(line, styles): JSX.Element {
return (
<div class={styles.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%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="7.4"
style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
}
function renderTramLinePicto(line, styles): JSX.Element {
const lineStyle = { fill: `#${line.backColorHexa}` };
return (
<div class={styles.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} />
<text x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11"
style={{ fill: "#00000" }}>
{line.shortName}
</text>
</svg>
</div>
);
}
function renderMetroLinePicto(line, styles): JSX.Element {
return (
<div class={styles.metroLinePicto}>
<svg viewbox="0 0 20 20">
<circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11" style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
}
function renderTrainLinePicto(line, styles): JSX.Element {
return (
<div class={styles.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%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11"
style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
}
export function renderLinePicto(line, styles): JSX.Element {
switch (line.transportMode) {
case "bus":
case "funicular":
return renderBusLinePicto(line, styles);
case "tram":
return renderTramLinePicto(line, styles);
/* case "val": */
case "metro":
return renderMetroLinePicto(line, styles);
case "transilien":
case "rer":
case "ter":
return renderTrainLinePicto(line, styles);
}
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"noImplicitAny": true,
"target": "ES6",
"moduleResolution": "node",
"allowJs": true,
"outDir": "build",
"strict": true,
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true,
"plugins": [
{
"name": "typescript-eslint-language-service"
}
]
},
"include": ["src"]
}

23
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
import basicSsl from '@vitejs/plugin-basic-ssl'
export default defineConfig({
plugins: [solidPlugin(), basicSsl()],
server: {
port: 3000,
base: '/widget',
proxy: {
'/widget/.*': {
target: 'https://localhost:3000/',
rewrite: (path) => {
console.error("PATH: ", path);
return path.replace(/\/widget/, '#');
},
},
},
},
build: {
target: 'esnext',
},
});