12 Commits

15 changed files with 570 additions and 378 deletions

View File

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

View File

@@ -1,6 +1,14 @@
from collections import defaultdict
from re import compile as re_compile
from time import time
from typing import AsyncIterator, ByteString, Callable, Iterable, List, Type
from typing import (
AsyncIterator,
ByteString,
Callable,
Iterable,
List,
Type,
)
from aiofiles import open as async_open
from aiohttp import ClientSession
@@ -8,10 +16,13 @@ from aiohttp import ClientSession
from msgspec import ValidationError
from msgspec.json import Decoder
from rich import print
from shapefile import Reader as ShapeFileReader, ShapeRecord
from ..db import Database
from ..models import Line, LinePicto, Stop, StopArea
from ..models import ConnectionArea, Line, LinePicto, Stop, StopArea, StopShape
from .idfm_types import (
ConnectionArea as IdfmConnectionArea,
Destinations as IdfmDestinations,
IdfmLineState,
IdfmResponse,
Line as IdfmLine,
@@ -55,6 +66,7 @@ class IdfmInterface:
self._json_stops_decoder = Decoder(type=List[IdfmStop])
self._json_stop_areas_decoder = Decoder(type=List[IdfmStopArea])
self._json_connection_areas_decoder = Decoder(type=List[IdfmConnectionArea])
self._json_lines_decoder = Decoder(type=List[IdfmLine])
self._json_stops_lines_assos_decoder = Decoder(type=List[IdfmStopLineAsso])
self._json_ratp_pictos_decoder = Decoder(type=List[RatpPicto])
@@ -66,7 +78,24 @@ class IdfmInterface:
async def startup(self) -> None:
BATCH_SIZE = 10000
STEPS: tuple[tuple[Type[Stop] | Type[StopArea], Callable, Callable], ...] = (
STEPS: tuple[
tuple[
Type[ConnectionArea] | Type[Stop] | Type[StopArea] | Type[StopShape],
Callable,
Callable,
],
...,
] = (
(
StopShape,
self._request_stop_shapes,
IdfmInterface._format_idfm_stop_shapes,
),
(
ConnectionArea,
self._request_idfm_connection_areas,
IdfmInterface._format_idfm_connection_areas,
),
(
StopArea,
self._request_idfm_stop_areas,
@@ -104,7 +133,7 @@ class IdfmInterface:
print(f"Link Stops to Lines: {time() - begin_ts}s")
begin_ts = time()
await self._load_stop_areas_stops_assos()
await self._load_stop_assos()
print(f"Link Stops to StopAreas: {time() - begin_ts}s")
async def _load_lines(self, batch_size: int = 5000) -> None:
@@ -167,25 +196,51 @@ class IdfmInterface:
print(f"{total_found_nb} line <-> stop ({total_assos_nb = } found)")
async def _load_stop_areas_stops_assos(self, batch_size: int = 5000) -> None:
total_assos_nb = total_found_nb = 0
assos = []
async def _load_stop_assos(self, batch_size: int = 5000) -> None:
total_assos_nb = area_stop_assos_nb = conn_stop_assos_nb = 0
area_stop_assos = []
connection_stop_assos = []
async for asso in self._request_idfm_stop_area_stop_associations():
fields = asso.fields
assos.append((int(fields.zdaid), int(fields.arrid)))
if len(assos) == batch_size:
stop_id = int(fields.arrid)
area_stop_assos.append((int(fields.zdaid), stop_id))
connection_stop_assos.append((int(fields.zdcid), stop_id))
if len(area_stop_assos) == batch_size:
total_assos_nb += batch_size
if (found_nb := await StopArea.add_stops(assos)) is not None:
total_found_nb += found_nb
assos.clear()
if assos:
total_assos_nb += len(assos)
if (found_nb := await StopArea.add_stops(assos)) is not None:
total_found_nb += found_nb
if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None:
area_stop_assos_nb += found_nb
area_stop_assos.clear()
print(f"{total_found_nb} stop area <-> stop ({total_assos_nb = } found)")
if (
found_nb := await ConnectionArea.add_stops(connection_stop_assos)
) is not None:
conn_stop_assos_nb += found_nb
connection_stop_assos.clear()
if area_stop_assos:
total_assos_nb += len(area_stop_assos)
if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None:
area_stop_assos_nb += found_nb
if (
found_nb := await ConnectionArea.add_stops(connection_stop_assos)
) is not None:
conn_stop_assos_nb += found_nb
print(f"{area_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)")
print(f"{conn_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)")
# TODO: This method is synchronous due to the shapefile library.
# It's not a blocking issue but it could be nice to find an alternative.
async def _request_stop_shapes(self) -> AsyncIterator[ShapeRecord]:
# TODO: Use HTTP
with ShapeFileReader("./tests/datasets/REF_LDA.zip") as reader:
for record in reader.shapeRecords():
yield record
async def _request_idfm_stops(self) -> AsyncIterator[IdfmStop]:
# headers = {"Accept": "application/json", "apikey": self._api_key}
@@ -206,6 +261,13 @@ class IdfmInterface:
for element in self._json_stop_areas_decoder.decode(await raw.read()):
yield element
async def _request_idfm_connection_areas(self) -> AsyncIterator[IdfmConnectionArea]:
async with async_open(
"./tests/datasets/zones-de-correspondance.json", "rb"
) as raw:
for element in self._json_connection_areas_decoder.decode(await raw.read()):
yield element
async def _request_idfm_lines(self) -> AsyncIterator[IdfmLine]:
# TODO: Use HTTP
async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw:
@@ -378,6 +440,34 @@ class IdfmInterface:
changed_ts=int(fields.zdachanged.timestamp()),
)
@staticmethod
def _format_idfm_connection_areas(
*connection_areas: IdfmConnectionArea,
) -> Iterable[ConnectionArea]:
for connection_area in connection_areas:
yield ConnectionArea(
id=int(connection_area.zdcid),
name=connection_area.zdcname,
town_name=connection_area.zdctown,
postal_region=connection_area.zdcpostalregion,
xepsg2154=connection_area.zdcxepsg2154,
yepsg2154=connection_area.zdcyepsg2154,
transport_mode=StopAreaType(connection_area.zdctype.value),
version=connection_area.zdcversion,
created_ts=int(connection_area.zdccreated.timestamp()),
changed_ts=int(connection_area.zdcchanged.timestamp()),
)
@staticmethod
def _format_idfm_stop_shapes(*shape_records: ShapeRecord) -> Iterable[StopShape]:
for shape_record in shape_records:
yield StopShape(
id=shape_record.record[1],
type=shape_record.shape.shapeType,
bounding_box=list(shape_record.shape.bbox),
points=shape_record.shape.points,
)
async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]:
begin_ts = time()
line_picto_path = line_picto_format = None
@@ -432,20 +522,40 @@ class IdfmInterface:
return ret
async def get_destinations(self, stop_point_id: str) -> Iterable[str]:
# TODO: Store in database the destination for the given stop and line id.
async def get_destinations(self, stop_id: int) -> IdfmDestinations | None:
begin_ts = time()
destinations: dict[str, str] = {}
if (res := await self.get_next_passages(stop_point_id)) is not None:
destinations: IdfmDestinations = defaultdict(set)
if (stop := await Stop.get_by_id(stop_id)) is not None:
expected_stop_ids = {stop.id}
elif (stop_area := await StopArea.get_by_id(stop_id)) is not None:
expected_stop_ids = {stop.id for stop in stop_area.stops}
else:
return None
if (res := await self.get_next_passages(stop_id)) is not None:
for delivery in res.Siri.ServiceDelivery.StopMonitoringDelivery:
if delivery.Status == IdfmState.true:
for stop_visit in delivery.MonitoredStopVisit:
monitoring_ref = stop_visit.MonitoringRef.value
try:
monitored_stop_id = int(monitoring_ref.split(":")[-2])
except (IndexError, ValueError):
print(f"Unable to get stop id from {monitoring_ref}")
continue
journey = stop_visit.MonitoredVehicleJourney
if (destination_name := journey.DestinationName) and (
line_ref := journey.LineRef
):
line_id = line_ref.value.replace("STIF:Line::", "")[:-1]
print(f"{line_id = }")
destinations[line_id] = destination_name[0].value
if (
dst_names := journey.DestinationName
) and monitored_stop_id in expected_stop_ids:
line_id = journey.LineRef.value.split(":")[-2]
destinations[line_id].add(dst_names[0].value)
print(f"get_next_passages: {time() - begin_ts}")
return destinations

View File

@@ -116,6 +116,19 @@ class StopArea(Struct):
record_timestamp: datetime
class ConnectionArea(Struct):
zdcid: str
zdcversion: str
zdccreated: datetime
zdcchanged: datetime
zdcname: str
zdcxepsg2154: int
zdcyepsg2154: int
zdctown: str
zdcpostalregion: str
zdctype: StopAreaType
class StopAreaStopAssociationFields(Struct, kw_only=True):
arrid: str # TODO: use int ?
artid: str | None = None
@@ -184,6 +197,8 @@ class Line(Struct):
Lines = dict[str, Line]
Destinations = dict[str, set[str]]
# TODO: Set structs frozen
class StopLineAssoFields(Struct):

View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Iterable, Self, Sequence, TYPE_CHECKING
from typing import Iterable, Sequence, TYPE_CHECKING
from sqlalchemy import (
BigInteger,
@@ -8,6 +8,8 @@ from sqlalchemy import (
Enum,
Float,
ForeignKey,
Integer,
JSON,
select,
String,
Table,
@@ -48,19 +50,26 @@ class _Stop(Base):
postal_region = mapped_column(String, nullable=False)
xepsg2154 = mapped_column(BigInteger, nullable=False)
yepsg2154 = mapped_column(BigInteger, nullable=False)
version = mapped_column(String, nullable=False)
created_ts = mapped_column(BigInteger)
changed_ts = mapped_column(BigInteger, nullable=False)
lines: Mapped[list[Line]] = relationship(
"Line",
secondary="line_stop_association_table",
back_populates="stops",
# lazy="joined",
lazy="selectin",
)
areas: Mapped[list["StopArea"]] = relationship(
"StopArea", secondary=stop_area_stop_association_table, back_populates="stops"
)
connection_area_id: Mapped[int] = mapped_column(
ForeignKey("connection_areas.id"), nullable=True
)
connection_area: Mapped["ConnectionArea"] = relationship(
back_populates="stops", lazy="selectin"
)
__tablename__ = "_stops"
__mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind}
@@ -108,6 +117,7 @@ class Stop(_Stop):
accessibility = mapped_column(Enum(IdfmState), nullable=False)
visual_signs_available = mapped_column(Enum(IdfmState), nullable=False)
audible_signs_available = mapped_column(Enum(IdfmState), nullable=False)
record_id = mapped_column(String, nullable=False)
record_ts = mapped_column(BigInteger, nullable=False)
@@ -120,12 +130,12 @@ class StopArea(_Stop):
id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
type = mapped_column(Enum(StopAreaType), nullable=False)
stops: Mapped[list["_Stop"]] = relationship(
"_Stop",
stops: Mapped[list["Stop"]] = relationship(
"Stop",
secondary=stop_area_stop_association_table,
back_populates="areas",
lazy="selectin",
# lazy="joined",
)
__tablename__ = "stop_areas"
@@ -147,17 +157,17 @@ class StopArea(_Stop):
stop_area_ids.add(stop_area_id)
stop_ids.add(stop_id)
stop_areas_res = await session.execute(
stop_areas_res = await session.scalars(
select(StopArea)
.where(StopArea.id.in_(stop_area_ids))
.options(selectinload(StopArea.stops))
)
stop_areas: dict[int, StopArea] = {
stop_area.id: stop_area for stop_area in stop_areas_res.scalars()
stop_area.id: stop_area for stop_area in stop_areas_res.all()
}
stop_res = await session.execute(select(_Stop).where(_Stop.id.in_(stop_ids)))
stops: dict[int, _Stop] = {stop.id: stop for stop in stop_res.scalars()}
stop_res = await session.execute(select(Stop).where(Stop.id.in_(stop_ids)))
stops: dict[int, Stop] = {stop.id: stop for stop in stop_res.scalars()}
found = 0
for stop_area_id, stop_id in stop_area_to_stop_ids:
@@ -173,3 +183,78 @@ class StopArea(_Stop):
await session.commit()
return found
class StopShape(Base):
db = db
id = mapped_column(BigInteger, primary_key=True) # Same id than ConnectionArea
type = mapped_column(Integer, nullable=False)
bounding_box = mapped_column(JSON)
points = mapped_column(JSON)
__tablename__ = "stop_shapes"
class ConnectionArea(Base):
db = db
id = mapped_column(BigInteger, primary_key=True)
name = mapped_column(String, nullable=False)
town_name = mapped_column(String, nullable=False)
postal_region = mapped_column(String, nullable=False)
xepsg2154 = mapped_column(BigInteger, nullable=False)
yepsg2154 = mapped_column(BigInteger, nullable=False)
transport_mode = mapped_column(Enum(StopAreaType), nullable=False)
version = mapped_column(String, nullable=False)
created_ts = mapped_column(BigInteger)
changed_ts = mapped_column(BigInteger, nullable=False)
stops: Mapped[list["_Stop"]] = relationship(back_populates="connection_area")
__tablename__ = "connection_areas"
# TODO: Merge with StopArea.add_stops
@classmethod
async def add_stops(
cls, conn_area_to_stop_ids: Iterable[tuple[int, int]]
) -> int | None:
session = cls.db.session
if session is None:
return None
conn_area_ids, stop_ids = set(), set()
for conn_area_id, stop_id in conn_area_to_stop_ids:
conn_area_ids.add(conn_area_id)
stop_ids.add(stop_id)
conn_area_res = await session.execute(
select(ConnectionArea)
.where(ConnectionArea.id.in_(conn_area_ids))
.options(selectinload(ConnectionArea.stops))
)
conn_areas: dict[int, ConnectionArea] = {
conn.id: conn for conn in conn_area_res.scalars()
}
stop_res = await session.execute(select(_Stop).where(_Stop.id.in_(stop_ids)))
stops: dict[int, _Stop] = {stop.id: stop for stop in stop_res.scalars()}
found = 0
for conn_area_id, stop_id in conn_area_to_stop_ids:
if (conn_area := conn_areas.get(conn_area_id)) is not None:
if (stop := stops.get(stop_id)) is not None:
conn_area.stops.append(stop)
found += 1
else:
print(f"No stop found for {stop_id} id")
else:
print(f"No connection area found for {conn_area_id}")
await session.commit()
return found

View File

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

View File

@@ -23,3 +23,10 @@ class StopArea(BaseModel):
type: StopAreaType
lines: list[str] # SNCF lines are linked to stop areas and not stops.
stops: list[Stop]
class StopShape(BaseModel):
id: int
type: int
bbox: list[float]
points: list[tuple[float, float]]

View File

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

View File

@@ -16,6 +16,7 @@ fastapi = "^0.88.0"
uvicorn = "^0.20.0"
asyncpg = "^0.27.0"
msgspec = "^0.12.0"
pyshp = "^2.3.1"
[build-system]
requires = ["poetry-core"]

View File

@@ -24,6 +24,7 @@
"@hope-ui/solid": "^0.6.7",
"@motionone/solid": "^10.15.5",
"@solid-primitives/date": "^2.0.5",
"@solid-primitives/scroll": "^2.0.10",
"@stitches/core": "^1.2.8",
"date-fns": "^2.29.3",
"leaflet": "^1.9.3",

View File

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

View File

@@ -1,12 +1,11 @@
import { VoidComponent, createResource, onMount, ParentComponent, ParentProps, Show, useContext, For } from 'solid-js';
import { createDateNow, getTime } from '@solid-primitives/date';
import { timeline } from '@motionone/dom';
import { AnimationOptions } from '@motionone/types';
import { Motion } from "@motionone/solid";
import { format } from "date-fns";
import { Line, TrafficStatus } from './types';
import { renderLineTransportMode, renderLinePicto } from './utils';
import { renderLineTransportMode, renderLinePicto, ScrollingText } from './utils';
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import "./passagesPanel.scss";
@@ -123,24 +122,6 @@ const DestinationPassages: VoidComponent<{ line: Line, destination: string }> =
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
let destinationViewboxRef: SVGSVGElement | undefined = undefined;
let destinationTextRef: SVGTextElement | undefined = undefined;
onMount(() => {
if (destinationViewboxRef !== undefined && destinationTextRef !== undefined) {
const overlap = destinationTextRef.getComputedTextLength() - destinationViewboxRef.viewBox.baseVal.width;
if (overlap > 0) {
timeline(
[
[destinationTextRef, { x: [-overlap] }, { duration: 5 }],
[destinationTextRef, { x: [0] }, { duration: 2 }],
],
{ repeat: Infinity },
);
}
}
});
return (
<div class="line">
<div class="transportMode">
@@ -148,14 +129,7 @@ const DestinationPassages: VoidComponent<{ line: Line, destination: string }> =
</div>
{renderLinePicto(props.line)}
<div class="destination">
<svg ref={destinationViewboxRef} viewBox="0 0 600 40">
<text ref={destinationTextRef} x="0" y="50%"
dominant-baseline="middle"
font-size="40"
style={{ fill: "#000000" }}>
{props.destination}
</text>
</svg>
<ScrollingText height={40} width={600} content={props.destination} />
</div>
<div class="trafficStatus">
<svg viewBox="0 0 51 51">

View File

@@ -35,7 +35,6 @@
overflow-y: scroll;
.stopPanel {
// display: None;
scroll-snap-align: center;
.stop {
@@ -53,16 +52,19 @@
border-bottom: solid calc(2px);
.name {
margin-left: calc(40/1920*100%);
width: 60%;
aspect-ratio: 2.5;
display: flex;
align-items: center;
font-family: IDFVoyageur-bold;
}
.lineRepr {
// height: 100%;
width: 40%;
aspect-ratio: 2.5;
// margin-left: auto;
display: flex;
flex-direction: row;
@@ -75,11 +77,6 @@
height: 50%;
}
.break {
flex-basis: 100%;
height: 0;
}
.linesRepresentationMatrix {
@extend %busLinePicto; // Use the larger picto aspect-ratio
width: 75%;
@@ -88,12 +85,6 @@
display: flex;
flex-flow: row;
flex-wrap: wrap;
// justify-content: space-around;
// .break {
// flex-basis: 100%;
// height: 0;
// }
%picto {
margin-left: 1%;
@@ -123,22 +114,19 @@
}
.metroLinePicto {
@extendnd %metroLinePicto;
@extend %metroLinePicto;
@extend %singleLinePicto;
}
.busLinePicto {
@extend %busLinePicto;
@extend %picto;
height: 40%;
}
}
}
}
// .stop:last-child {
// border-bottom: 0;
// /* to make up for the bottom border deletion */
// padding-bottom: calc(2px);
// }
}
.displayed {
display: block;
@@ -150,93 +138,4 @@
width: 50%;
}
}
// .lineTable {
// height: 100%;
// width: 50%;
// tr {
// height: 100%;
// display: flex;
// flex-flow: row;
// }
// td {
// height: 100%;
// display: flex;
// flex-flow: row;
// }
// }
// .stop {
// height: 100%;
// display: flex;
// flex-direction: row;
// align-items: center;
// .lineRepr {
// height: 100%;
// aspect-ratio: 5;
// display: flex;
// flex-direction: row;
// flex-wrap: wrap;
// align-items: center;
// .break {
// flex-basis: 100%;
// height: 0;
// }
// .linesRepresentationMatrix {
// @extend %busLinePicto; // Use the larger picto aspect-ratio
// height: 100%;
// aspect-ratio: 3;
// display: flex;
// flex-flow: row;
// flex-wrap: wrap;
// .break {
// flex-basis: 100%;
// height: 0;
// }
// %picto {
// margin-left: 1%;
// align-self: center;
// justify-self: center;
// }
// .transportMode {
// @extend %transportMode;
// @extend %picto;
// }
// .tramLinePicto {
// @extendnd %tramLinePicto;
// @extend %picto;
// }
// .trainLinePicto {
// @extend %trainLinePicto;
// @extend %picto;
// }
// .metroLinePicto {
// @extendnd %metroLinePicto;
// @extend %picto;
// }
// .busLinePicto {
// @extend %busLinePicto;
// @extend %picto;
// }
// }
// }
// }
}

View File

@@ -9,7 +9,7 @@ import {
} from 'leaflet';
import { Stop } from './types';
import { PositionedPanel, renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils';
import { PositionedPanel, renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from './utils';
import { AppContextContext, AppContextStore } from "./appContext";
import { BusinessDataContext, BusinessDataStore } from "./businessData";
@@ -191,10 +191,10 @@ const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
type ByTransportModeReprs = {
mode: JSX.Element | undefined;
lines: Record<string, JSX.Element | JSX.Element[] | undefined>;
};
}
const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 10;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
@@ -248,7 +248,9 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
return (
<div class="stop" onClick={() => setDisplayedStops([props.stop])}>
<div class="name">{props.stop.name}</div>
<div class="name" >
<ScrollingText height={fontSize} width={100} content={props.stop.name} />
</div>
{lineReprs()}
</div>
);

View File

@@ -1,4 +1,5 @@
import { JSX } from 'solid-js';
import { JSX, onMount, VoidComponent } from 'solid-js';
import { timeline } from '@motionone/dom';
import { Line } from './types';
@@ -129,3 +130,37 @@ export type PositionedPanel = {
// TODO: Should be PassagesPanelComponent ?
panel: JSX.Element;
};
export const ScrollingText: VoidComponent<{ height: number, width: number, content: string }> = (props) => {
let viewBoxRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined;
onMount(() => {
if (viewBoxRef !== undefined && textRef !== undefined) {
const overlap = textRef.getComputedTextLength() - viewBoxRef.viewBox.baseVal.width;
if (overlap > 0) {
timeline(
[
[textRef, { x: [-overlap] }, { duration: 5 }],
[textRef, { x: [0] }, { duration: 2 }],
],
{ repeat: Infinity },
);
}
}
});
return (
<svg ref={viewBoxRef} viewBox={`0 0 ${props.width} ${props.height}`}>
<text
ref={textRef}
x="0%" y="55%"
dominant-baseline="middle"
font-size={`${props.height}px`}>
{props.content}
</text>
</svg >
);
}