15 Commits

Author SHA1 Message Date
e81f81b7a7 🚸 Add debounce mechanism to stop name input 2023-04-22 12:31:26 +02:00
61610fa2ba Merge branch 'add-stop-area-repr-and-stop-destinations' into develop 2023-04-22 12:31:01 +02:00
ee14d60db7 🐛 Old stops still displayed on map once the stop search narrowed 2023-04-15 18:24:47 +02:00
a2728cfc0c 💄 Redesign StopSearchMenu (map panel)
- Replace leaflet with openlayers
- Add stop areas shape to map
- Display stop destinations sub-panel on click
2023-04-14 11:56:41 +02:00
0a7d74a215 ♻️ Store StopArea Stops in BusinessDataStore when looking for stops 2023-04-14 11:36:54 +02:00
1b713dbc0e Add StopDestinations to the frontend business data 2023-04-14 11:33:29 +02:00
1ffd3cbe94 Add StopShape to the frontend business data 2023-04-14 11:29:25 +02:00
42817f7b0c 🚚 Fix the transport mode location issue 2023-04-14 11:20:19 +02:00
440a5faf3c 🗃️ Update StopArea db models: StopArea can´t be composed of another StopAreas 2023-04-13 21:40:35 +02:00
61097fe9e2 Add /stop_shape/{stop_id} endpoint 2023-04-13 21:35:41 +02:00
62a9000ec2 Add /stop/{stop_id}/destinations endpoint 2023-04-13 21:35:41 +02:00
62b6425255 ♻️ Update stop destinations returned value 2023-04-13 21:35:41 +02:00
ac06df9f87 Handle IDFM stop areas shapes 2023-04-13 21:35:29 +02:00
ecfb3c8cb3 Handle IDFM connection areas 2023-04-13 20:57:15 +02:00
293a1391bc Add ConnectionArea and StopShape models + Stop-ConnectionArea relationship 2023-04-13 20:55:56 +02:00
16 changed files with 894 additions and 145 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Iterable, Self, Sequence, TYPE_CHECKING from typing import Iterable, Sequence, TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
@@ -8,6 +8,8 @@ from sqlalchemy import (
Enum, Enum,
Float, Float,
ForeignKey, ForeignKey,
Integer,
JSON,
select, select,
String, String,
Table, Table,
@@ -48,19 +50,26 @@ class _Stop(Base):
postal_region = mapped_column(String, nullable=False) postal_region = mapped_column(String, nullable=False)
xepsg2154 = mapped_column(BigInteger, nullable=False) xepsg2154 = mapped_column(BigInteger, nullable=False)
yepsg2154 = mapped_column(BigInteger, nullable=False) yepsg2154 = mapped_column(BigInteger, nullable=False)
version = mapped_column(String, nullable=False) version = mapped_column(String, nullable=False)
created_ts = mapped_column(BigInteger) created_ts = mapped_column(BigInteger)
changed_ts = mapped_column(BigInteger, nullable=False) changed_ts = mapped_column(BigInteger, nullable=False)
lines: Mapped[list[Line]] = relationship( lines: Mapped[list[Line]] = relationship(
"Line", "Line",
secondary="line_stop_association_table", secondary="line_stop_association_table",
back_populates="stops", back_populates="stops",
# lazy="joined",
lazy="selectin", lazy="selectin",
) )
areas: Mapped[list["StopArea"]] = relationship( areas: Mapped[list["StopArea"]] = relationship(
"StopArea", secondary=stop_area_stop_association_table, back_populates="stops" "StopArea", secondary=stop_area_stop_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" __tablename__ = "_stops"
__mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind} __mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind}
@@ -108,6 +117,7 @@ class Stop(_Stop):
accessibility = mapped_column(Enum(IdfmState), nullable=False) accessibility = mapped_column(Enum(IdfmState), nullable=False)
visual_signs_available = mapped_column(Enum(IdfmState), nullable=False) visual_signs_available = mapped_column(Enum(IdfmState), nullable=False)
audible_signs_available = mapped_column(Enum(IdfmState), nullable=False) audible_signs_available = mapped_column(Enum(IdfmState), nullable=False)
record_id = mapped_column(String, nullable=False) record_id = mapped_column(String, nullable=False)
record_ts = mapped_column(BigInteger, nullable=False) record_ts = mapped_column(BigInteger, nullable=False)
@@ -120,12 +130,12 @@ class StopArea(_Stop):
id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True) id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
type = mapped_column(Enum(StopAreaType), nullable=False) type = mapped_column(Enum(StopAreaType), nullable=False)
stops: Mapped[list["_Stop"]] = relationship(
"_Stop", stops: Mapped[list["Stop"]] = relationship(
"Stop",
secondary=stop_area_stop_association_table, secondary=stop_area_stop_association_table,
back_populates="areas", back_populates="areas",
lazy="selectin", lazy="selectin",
# lazy="joined",
) )
__tablename__ = "stop_areas" __tablename__ = "stop_areas"
@@ -147,17 +157,17 @@ class StopArea(_Stop):
stop_area_ids.add(stop_area_id) stop_area_ids.add(stop_area_id)
stop_ids.add(stop_id) stop_ids.add(stop_id)
stop_areas_res = await session.execute( stop_areas_res = await session.scalars(
select(StopArea) select(StopArea)
.where(StopArea.id.in_(stop_area_ids)) .where(StopArea.id.in_(stop_area_ids))
.options(selectinload(StopArea.stops)) .options(selectinload(StopArea.stops))
) )
stop_areas: dict[int, StopArea] = { 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))) 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()} stops: dict[int, Stop] = {stop.id: stop for stop in stop_res.scalars()}
found = 0 found = 0
for stop_area_id, stop_id in stop_area_to_stop_ids: for stop_area_id, stop_id in stop_area_to_stop_ids:
@@ -173,3 +183,78 @@ class StopArea(_Stop):
await session.commit() await session.commit()
return found 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 .line import Line, TransportMode
from .next_passage import NextPassage, NextPassages from .next_passage import NextPassage, NextPassages
from .stop import Stop, StopArea from .stop import Stop, StopArea, StopShape
__all__ = ["Line", "NextPassage", "NextPassages", "Stop", "StopArea", "TransportMode"]
__all__ = [
"Line",
"NextPassage",
"NextPassages",
"Stop",
"StopArea",
"StopShape",
"TransportMode",
]

View File

@@ -23,3 +23,10 @@ class StopArea(BaseModel):
type: StopAreaType type: StopAreaType
lines: list[str] # SNCF lines are linked to stop areas and not stops. lines: list[str] # SNCF lines are linked to stop areas and not stops.
stops: list[Stop] stops: list[Stop]
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 rich import print
from backend.db import db from backend.db import db
from backend.idfm_interface import IdfmInterface from backend.idfm_interface import Destinations as IdfmDestinations, IdfmInterface
from backend.models import Line, Stop, StopArea from backend.models import Line, Stop, StopArea, StopShape
from backend.schemas import ( from backend.schemas import (
Line as LineSchema, Line as LineSchema,
TransportMode, TransportMode,
@@ -18,6 +18,7 @@ from backend.schemas import (
NextPassages as NextPassagesSchema, NextPassages as NextPassagesSchema,
Stop as StopSchema, Stop as StopSchema,
StopArea as StopAreaSchema, StopArea as StopAreaSchema,
StopShape as StopShapeSchema,
) )
API_KEY = environ.get("API_KEY") 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(), ts=service_delivery.ResponseTimestamp.timestamp(),
passages=by_line_by_dst_passages, passages=by_line_by_dst_passages,
) )
@app.get("/stop/{stop_id}/destinations")
async def get_stop_destinations(
stop_id: int,
) -> IdfmDestinations | None:
destinations = await idfm_interface.get_destinations(stop_id)
return destinations
# 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" uvicorn = "^0.20.0"
asyncpg = "^0.27.0" asyncpg = "^0.27.0"
msgspec = "^0.12.0" msgspec = "^0.12.0"
pyshp = "^2.3.1"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View File

@@ -12,9 +12,11 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.0", "@types/leaflet": "^1.9.0",
"@types/proj4": "^2.5.2",
"@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-basic-ssl": "^1.0.1",
"eslint": "^8.32.0", "eslint": "^8.32.0",
"eslint-plugin-solid": "^0.9.3", "eslint-plugin-solid": "^0.9.3",
"sass": "^1.62.0",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"typescript-eslint-language-service": "^5.0.0", "typescript-eslint-language-service": "^5.0.0",
"vite": "^4.0.3", "vite": "^4.0.3",
@@ -27,9 +29,9 @@
"@solid-primitives/scroll": "^2.0.10", "@solid-primitives/scroll": "^2.0.10",
"@stitches/core": "^1.2.8", "@stitches/core": "^1.2.8",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"leaflet": "^1.9.3",
"matrix-widget-api": "^1.1.1", "matrix-widget-api": "^1.1.1",
"sass": "^1.58.3", "ol": "^7.3.0",
"proj4": "^2.9.0",
"solid-js": "^1.6.6", "solid-js": "^1.6.6",
"solid-transition-group": "^0.0.10" "solid-transition-group": "^0.0.10"
} }

View File

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

View File

@@ -1,9 +1,11 @@
import { batch, createContext, createSignal, JSX } from 'solid-js'; import { batch, createContext, createSignal, JSX } from 'solid-js';
import { createStore } from 'solid-js/store'; import { createStore } from 'solid-js/store';
import { Line, Lines, Passage, Passages, Stop, Stops } from './types'; import { Line, Lines, Passage, Passages, Stop, StopShape, StopShapes, Stops } from './types';
export type StopDestinations = Record<string, string[]>;
export interface BusinessDataStore { export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>; getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>; getLinePassages: (lineId: string) => Record<string, Passage[]>;
@@ -18,6 +20,9 @@ export interface BusinessDataStore {
getStop: (stopId: number) => Stop | undefined; getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>; searchStopByName: (name: string) => Promise<Stops>;
getStopDestinations: (stopId: number) => Promise<StopDestinations>;
getStopShape: (stopId: number) => Promise<StopShape | undefined>;
}; };
export const BusinessDataContext = createContext<BusinessDataStore>(); export const BusinessDataContext = createContext<BusinessDataStore>();
@@ -30,9 +35,10 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
lines: Lines; lines: Lines;
passages: Passages; passages: Passages;
stops: Stops; stops: Stops;
stopShapes: StopShapes;
}; };
const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {} }); const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {}, stopShapes: {} });
const getLine = async (lineId: string): Promise<Line> => { const getLine = async (lineId: string): Promise<Line> => {
let line = store.lines[lineId]; let line = store.lines[lineId];
@@ -55,6 +61,7 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
return Object.keys(store.passages[lineId]); return Object.keys(store.passages[lineId]);
} }
// TODO: Remove this method: it's based on the next passages and return nothing until the refreshPassages is called.
const getDestinationPassages = (lineId: string, destination: string): Passage[] => { const getDestinationPassages = (lineId: string, destination: string): Passage[] => {
return store.passages[lineId][destination]; return store.passages[lineId][destination];
} }
@@ -157,14 +164,39 @@ ${linePassagesDestination.length} here... refresh all them.`);
for (const stop of stops) { for (const stop of stops) {
byIdStops[stop.id] = stop; byIdStops[stop.id] = stop;
setStore('stops', stop.id, stop); setStore('stops', stop.id, stop);
for (const innerStop of stop.stops) {
setStore('stops', innerStop.id, innerStop);
}
} }
return byIdStops; return byIdStops;
} }
const getStopDestinations = async (stopId: number): Promise<StopDestinations> => {
const data = await fetch(`${serverUrl()}/stop/${stopId}/destinations`, {
headers: { 'Content-Type': 'application/json' }
});
const response = await data.json();
return response;
}
const getStopShape = async (stopId: number): Promise<StopShape | undefined> => {
let shape = store.stopShapes[stopId];
if (shape === undefined) {
console.log(`No shape found for ${stopId} stop... fetch it from backend.`);
const data = await fetch(`${serverUrl()}/stop_shape/${stopId}`, {
headers: { 'Content-Type': 'application/json' }
});
shape = await data.json();
setStore('stopShapes', stopId, shape);
}
return shape;
}
return ( return (
<BusinessDataContext.Provider value={{ <BusinessDataContext.Provider value={{
getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds, getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds,
refreshPassages, addPassages, clearPassages, getStop, searchStopByName refreshPassages, addPassages, clearPassages,
getStop, getStopDestinations, getStopShape, searchStopByName
}}> }}>
{props.children} {props.children}
</BusinessDataContext.Provider> </BusinessDataContext.Provider>

View File

@@ -51,6 +51,8 @@
/* TODO: Disable border-bottom for the last .line */ /* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px); border-bottom: solid calc(2px);
cursor: default;
.name { .name {
margin-left: calc(40/1920*100%); margin-left: calc(40/1920*100%);
width: 60%; width: 60%;
@@ -134,8 +136,87 @@
} }
.map { .map {
position: relative;
height: 100%; height: 100%;
width: 50%; width: 50%;
.ol-viewport {
@extend %body;
position: absolute;
margin: 0;
}
.popup {
@extend %body;
margin: 0;
position: absolute;
width: 100%;
height: 35%;
border: solid var(--idfm-white) calc(0.2*1vh);
background-color: var(--idfm-black);
z-index: 1;
visibility: hidden;
.header {
@extend %header;
color: var(--idfm-white);
}
.body {
@extend %body;
scroll-snap-type: y mandatory;
overflow-y: scroll;
.line {
scroll-snap-align: center;
height: calc(100% / 3);
margin: 0 calc(10/1920*100%);
display: flex;
flex-direction: row;
align-items: center;
font-family: IDFVoyageur-bold;
.busLinePicto {
@extend %busLinePicto;
height: 80%;
width: 30%;
}
.name {
width: 100%;
height: 60%;
}
div {
height: 100%;
svg {
max-width: 100%;
max-height: 100%;
}
}
}
}
.footer {
@extend %footer;
}
}
.displayed {
visibility: visible;
}
} }
} }
} }

View File

@@ -1,30 +1,48 @@
import { createContext, createEffect, createResource, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js'; import { createContext, createEffect, createResource, createSignal, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js';
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { createScrollPosition } from "@solid-primitives/scroll"; import { createScrollPosition } from "@solid-primitives/scroll";
import { Input, InputLeftAddon, InputGroup } from "@hope-ui/solid"; import { Input, InputLeftAddon, InputGroup } from "@hope-ui/solid";
import {
featureGroup as leafletFeatureGroup, LatLngLiteral as LeafletLatLngLiteral, Map as LeafletMap,
Marker as LeafletMarker, tileLayer as leafletTileLayer
} from 'leaflet';
import { Stop } from './types'; import OlFeature from 'ol/Feature';
import OlMap from 'ol/Map';
import OlView from 'ol/View';
import { isEmpty as isEmptyExtend } from 'ol/extent';
import { FeatureLike as OlFeatureLike } from 'ol/Feature';
import OlOSM from 'ol/source/OSM';
import OlOverlay from 'ol/Overlay';
import OlPoint from 'ol/geom/Point';
import OlPolygon from 'ol/geom/Polygon';
import OlVectorSource from 'ol/source/Vector';
import { fromLonLat, toLonLat } from 'ol/proj';
import { Tile as OlTileLayer, Vector as OlVectorLayer } from 'ol/layer';
import { Circle, Fill, Stroke, Style } from 'ol/style';
import { easeOut } from 'ol/easing';
import { getVectorContext } from 'ol/render';
import { unByKey } from 'ol/Observable';
import { register } from 'ol/proj/proj4';
import proj4 from 'proj4';
import { Stop, StopShape } from './types';
import { PositionedPanel, renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from './utils'; import { PositionedPanel, renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from './utils';
import { AppContextContext, AppContextStore } from "./appContext"; import { AppContextContext, AppContextStore } from "./appContext";
import { BusinessDataContext, BusinessDataStore } from "./businessData"; import { BusinessDataContext, BusinessDataStore } from "./businessData";
import "leaflet/dist/leaflet.css";
import "./stopsSearchMenu.scss"; import "./stopsSearchMenu.scss";
type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>; proj4.defs("EPSG:2154", "+proj=lcc +lat_1=49 +lat_2=44 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
register(proj4);
type ByStopIdMapFeatures = Record<number, OlFeature>;
interface SearchStore { interface SearchStore {
getSearchText: () => string; getSearchText: () => string;
setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>; setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>;
isSearchInProgress: () => boolean;
getFoundStops: () => Stop[]; getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void; setFoundStops: (stops: Stop[]) => void;
@@ -32,49 +50,73 @@ interface SearchStore {
getDisplayedPanelId: () => number; getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void; setDisplayedPanelId: (panelId: number) => void;
addMarkers: (stopId: number, markers: LeafletMarker[]) => void;
getPanels: () => PositionedPanel[]; getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void; setPanels: (panels: PositionedPanel[]) => void;
getHighlightedStop: () => Stop | undefined;
setHighlightedStop: (stop: Stop) => void;
resetHighlightedStop: () => void;
getMapFeature: (stopId: number) => OlFeature | undefined;
getAllMapFeatures: () => ByStopIdMapFeatures;
setMapFeature: (stopId: number, feature: OlFeature) => void;
}; };
const SearchContext = createContext<SearchStore>(); const SearchContext = createContext<SearchStore>();
function SearchProvider(props: { children: JSX.Element }) { function SearchProvider(props: { children: JSX.Element }) {
const searchTextDelayMs = 1500;
type Store = { type Store = {
searchText: string; searchText: string;
searchInProgress: boolean; searchPromise: Promise<void> | undefined;
foundStops: Stop[]; foundStops: Stop[];
markers: ByStopIdMarkers;
displayedPanelId: number; displayedPanelId: number;
panels: PositionedPanel[]; panels: PositionedPanel[];
highlightedStop: Stop | undefined;
mapFeatures: ByStopIdMapFeatures;
}; };
const [store, setStore] = createStore<Store>({ const [store, setStore] = createStore<Store>({
searchText: "", searchInProgress: false, foundStops: [], markers: {}, displayedPanelId: 0, panels: [] searchText: "",
searchPromise: undefined,
foundStops: [],
displayedPanelId: 0,
panels: [],
highlightedStop: undefined,
mapFeatures: {},
}); });
const getSearchText = (): string => { const getSearchText = (): string => {
return store.searchText; return store.searchText;
} }
const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => { const debounce = async (fn: (...args: any[]) => Promise<void>, delayMs: number) => {
setStore('searchInProgress', true); let timerId: number;
return new Promise((...args) => {
clearTimeout(timerId);
timerId = setTimeout(fn, delayMs, ...args);
});
}
const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => {
setStore('searchText', text); setStore('searchText', text);
if (store.searchPromise === undefined) {
const { searchStopByName } = businessDataStore; const { searchStopByName } = businessDataStore;
console.log("store.searchText=", store.searchText); const promise: Promise<void> = debounce(async (onSuccess: () => void) => {
console.log(`Fetching data for "${store.searchText}" stop name`);
const stopsById = await searchStopByName(store.searchText); const stopsById = await searchStopByName(store.searchText);
console.log("stopsById=", stopsById); console.log("stopsById=", stopsById);
setFoundStops(Object.values(stopsById)); setFoundStops(Object.values(stopsById));
onSuccess();
setStore('searchInProgress', false); }, searchTextDelayMs).then(() => {
setStore('searchPromise', undefined);
});
setStore('searchPromise', promise);
} }
const isSearchInProgress = (): boolean => {
return store.searchInProgress;
} }
const getFoundStops = (): Stop[] => { const getFoundStops = (): Stop[] => {
@@ -93,24 +135,48 @@ function SearchProvider(props: { children: JSX.Element }) {
setStore('displayedPanelId', panelId); setStore('displayedPanelId', panelId);
} }
const addMarkers = (stopId: number, markers: L.Marker[]): void => {
setStore('markers', stopId, markers);
}
const getPanels = (): PositionedPanel[] => { const getPanels = (): PositionedPanel[] => {
return store.panels; return store.panels;
} }
const setPanels = (panels: PositionedPanel[]): void => { const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels); setStore('panels', panels);
} }
const getHighlightedStop = (): Stop | undefined => {
return store.highlightedStop;
}
const setHighlightedStop = (stop: Stop): void => {
setStore('highlightedStop', stop);
}
const resetHighlightedStop = (): void => {
setStore('highlightedStop', undefined);
}
const getAllMapFeatures = (): ByStopIdMapFeatures => {
return store.mapFeatures;
}
const getMapFeature = (stopId: number): OlFeature | undefined => {
return store.mapFeatures[stopId];
}
const setMapFeature = (stopId: number, feature: OlFeature): void => {
setStore('mapFeatures', stopId, feature);
};
return ( return (
<SearchContext.Provider value={{ <SearchContext.Provider value={{
getSearchText, setSearchText, isSearchInProgress, getSearchText, setSearchText,
getFoundStops, setFoundStops, getFoundStops, setFoundStops,
getDisplayedPanelId, setDisplayedPanelId, getDisplayedPanelId, setDisplayedPanelId,
addMarkers, getPanels, setPanels,
getPanels, setPanels getHighlightedStop, setHighlightedStop, resetHighlightedStop,
getMapFeature, getAllMapFeatures, setMapFeature,
}}> }}>
{props.children} {props.children}
</SearchContext.Provider> </SearchContext.Provider>
@@ -126,13 +192,12 @@ const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) =>
if (businessDataStore === undefined || searchStore === undefined) if (businessDataStore === undefined || searchStore === undefined)
return <div />; return <div />;
const { isSearchInProgress, setSearchText } = searchStore; const { setSearchText } = searchStore;
const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => { const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => {
/* TODO: Add a tempo before fetching stop for giving time to user to finish his request */ /* TODO: Add a tempo before fetching stop for giving time to user to finish his request */
const stopName = event.currentTarget.value; const stopName = event.currentTarget.value;
if (stopName.length >= props.minCharsNb) { if (stopName.length >= props.minCharsNb) {
console.log(`Fetching data for "${stopName}" stop name`);
await setSearchText(stopName, businessDataStore); await setSearchText(stopName, businessDataStore);
} }
} }
@@ -149,7 +214,7 @@ const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) =>
<div class="inputGroup"> <div class="inputGroup">
<InputGroup > <InputGroup >
<InputLeftAddon>🚉 🚏</InputLeftAddon> <InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={onStopNameInput} readOnly={isSearchInProgress()} placeholder="Stop name..." /> <Input onInput={onStopNameInput} placeholder="Stop name..." />
</InputGroup> </InputGroup>
</div> </div>
</div > </div >
@@ -158,6 +223,7 @@ const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) =>
const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 40;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined) if (businessDataStore === undefined)
@@ -181,7 +247,14 @@ const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
return ( return (
<div class="stop"> <div class="stop">
<div class="name">{props.stop.name}</div> <svg class="name" viewBox={`0 0 215 ${fontSize}`}>
<text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={fontSize}>
{props.stop.name}
</text>
</svg>
<For each={lineReprs()}>{(line: JSX.Element) => line}</For> <For each={lineReprs()}>{(line: JSX.Element) => line}</For>
</div> </div>
); );
@@ -198,11 +271,15 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext); const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
if (businessDataStore === undefined || appContextStore === undefined) const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || appContextStore === undefined || searchStore === undefined)
return <div />; return <div />;
const { getLine } = businessDataStore; const { getLine } = businessDataStore;
const { setDisplayedStops } = appContextStore; const { setDisplayedStops } = appContextStore;
const { setHighlightedStop, resetHighlightedStop } = searchStore;
const fetchLinesRepr = async (stop: Stop): Promise<JSX.Element> => { const fetchLinesRepr = async (stop: Stop): Promise<JSX.Element> => {
const lineIds = new Set(stop.lines); const lineIds = new Set(stop.lines);
@@ -247,7 +324,12 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const [lineReprs] = createResource(props.stop, fetchLinesRepr); const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return ( return (
<div class="stop" onClick={() => setDisplayedStops([props.stop])}> <div
class="stop"
onClick={() => setDisplayedStops([props.stop])}
onMouseEnter={() => setHighlightedStop(props.stop)}
onMouseLeave={resetHighlightedStop}
>
<div class="name" > <div class="name" >
<ScrollingText height={fontSize} width={100} content={props.stop.name} /> <ScrollingText height={fontSize} width={100} content={props.stop.name} />
</div> </div>
@@ -257,8 +339,9 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
} }
const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => { const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => {
return ( return (
<div classList={{ ["stopPanel"]: true, ["displayed"]: props.show }}> <div classList={{ "stopPanel": true, "displayed": props.show }}>
<For each={props.stops.sort((x, y) => x.name.localeCompare(y.name))}> <For each={props.stops.sort((x, y) => x.name.localeCompare(y.name))}>
{(stop) => { {(stop) => {
return <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}> return <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
@@ -286,8 +369,7 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
createEffect(() => { createEffect(() => {
yStopsPanelsScroll(); yStopsPanelsScroll();
for (const panel of Object.values(getPanels())) { for (const panel of getPanels()) {
if (panel.panel) {
const panelDiv = panel.panel(); const panelDiv = panel.panel();
const panelDivClientRect = panelDiv.getBoundingClientRect(); const panelDivClientRect = panelDiv.getBoundingClientRect();
if (panelDivClientRect.y > 0) { if (panelDivClientRect.y > 0) {
@@ -295,7 +377,6 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
break; break;
} }
} }
}
}); });
return ( return (
@@ -335,64 +416,318 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
); );
} }
const Map: VoidComponent<{}> = () => {
const mapCenter: LeafletLatLngLiteral = { lat: 48.853, lng: 2.35 }; const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext); if (businessDataStore === undefined)
if (searchStore === undefined)
return <div />; return <div />;
const { addMarkers, getFoundStops } = searchStore; const { getLine, getStopDestinations } = businessDataStore;
let popupDiv: HTMLDivElement | undefined = undefined;
let mapDiv: any; const getDestinations = async (stop: Stop): Promise<{ lineId: string, destinations: string[] }[]> => {
let map: LeafletMap | undefined = undefined; let ret = [];
const stopsLayerGroup = leafletFeatureGroup();
const buildMap = (div: HTMLDivElement) => { if (stop !== undefined) {
map = new LeafletMap(div).setView(mapCenter, 11); const result = await getStopDestinations(stop.id);
leafletTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { for (const [lineId, destinations] of Object.entries(result)) {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' const line = await getLine(lineId);
}).addTo(map); const linePicto = renderLinePicto(line);
stopsLayerGroup.addTo(map); ret.push({ lineId: linePicto, destinations: destinations });
}
} }
const setMarker = (stop: Stop): L.Marker[] => { return ret;
const markers = []; }
if (stop.lat !== undefined && stop.lon !== undefined) { const [destinations] = createResource(() => props.stop, getDestinations);
/* TODO: Add stop lines representation to popup. */
markers.push(new LeafletMarker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup()); return (
<div ref={popupDiv} classList={{ "popup": true, "displayed": props.show }}>
<div class="header">{props.stop?.name}</div>
<div class="body">
<For each={destinations()}>
{(dst) => {
return <div class='line'>
{dst.lineId}
<div class="name">
<ScrollingText height={10} width={130} content={dst.destinations.join('/')} />
</div>
</div>;
}}
</For>
</div>
</div >
);
}
// TODO: Use boolean to set MapStop selected
const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { getStopShape } = businessDataStore;
const { setMapFeature } = searchStore;
const stopStyle = new Style({
image: new Circle({
fill: undefined,
stroke: new Stroke({ color: '#3399CC', width: 1.5 }),
radius: 10,
}),
});
const selectedStopStyle = new Style({
image: new Circle({
fill: undefined,
stroke: new Stroke({ color: 'purple', width: 2 }),
radius: 10,
}),
});
const stopAreaStyle = new Style({
stroke: new Stroke({ color: 'red' }),
fill: new Fill({ color: 'rgba(255,255,255,0.2)' }),
});
const getShape = async (stopId: number): Promise<StopShape | undefined> => {
return await getStopShape(stopId);
};
const [shape] = createResource<StopShape | undefined, number>(props.stop.id, getShape);
createEffect(() => {
const shape_ = shape();
if (shape_ === undefined) {
return;
}
let feature = undefined;
if (props.stop.lat !== undefined && props.stop.lon !== undefined) {
const selectStopStyle = () => {
return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle);
}
feature = new OlFeature({
geometry: new OlPoint(fromLonLat([props.stop.lon, props.stop.lat])),
});
feature.setStyle(selectStopStyle);
}
else {
let geometry = undefined;
const areaShape = shape();
if (areaShape !== undefined) {
const transformed = areaShape.points.map(point => fromLonLat(toLonLat(point, 'EPSG:2154')));
geometry = new OlPolygon([transformed.slice(0, -1)]);
} }
else { else {
for (const _stop of stop.stops) { geometry = new OlPoint(fromLonLat([props.stop.lon, props.stop.lat]));
markers.push(...setMarker(_stop)); }
feature = new OlFeature({ geometry: geometry });
feature.setStyle(stopAreaStyle);
}
feature.setId(props.stop.id);
setMapFeature(props.stop.id, feature);
});
return <For each={props.stop.stops}>{stop => <MapStop stop={stop} selected={props.selected} />}</For>;
}
const Map: ParentComponent<{}> = () => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { getStop } = businessDataStore;
const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore;
const [selectedMapStop, setSelectedMapStop] = createSignal<Stop | undefined>(undefined);
const [isPopupDisplayed, setPopupDisplayed] = createSignal<boolean>(false);
const mapCenter = [260769.80336542107, 6250587.867330259]; // EPSG:3857
const fitDurationMs = 1500;
const flashDurationMs = 2000;
// TODO: Set padding according to the marker design.
const fitPointsPadding = [50, 50, 50, 50];
let mapDiv: HTMLDivElement | undefined = undefined;
let popup: StopPopup | undefined = undefined;
const stopVectorSource = new OlVectorSource({ features: [] });
const stopVectorLayer = new OlVectorLayer({ source: stopVectorSource });
let overlay: OlOverlay | undefined = undefined;
let map: OlMap | undefined = undefined;
const displayedFeatures: Record<number, OlFeature> = {};
const buildMap = (div: HTMLDivElement): void => {
overlay = new OlOverlay({
element: popup,
autoPan: {
animation: {
duration: 250,
},
},
});
map = new OlMap({
target: div,
controls: [], // remove controls
view: new OlView({
center: mapCenter,
zoom: 10,
}),
layers: [
new OlTileLayer({
source: new OlOSM(),
}),
stopVectorLayer,
],
overlays: [overlay],
});
map.on('singleclick', onClickedMap);
}
const onClickedMap = async (event): Promise<void> => {
const features = await stopVectorLayer.getFeatures(event.pixel);
// Handle only the first feature
if (features.length > 0) {
await onClickedFeature(features[0]);
}
else {
setPopupDisplayed(false);
setSelectedMapStop(undefined);
} }
} }
return markers;
const onClickedFeature = async (feature: OlFeatureLike): Promise<void> => {
const stopId: number = feature.getId();
const stop = getStop(stopId);
// TODO: Handle StopArea (use center given by the backend)
if (stop?.lat !== undefined && stop?.lon !== undefined) {
setSelectedMapStop(stop);
map?.getView().animate(
{
center: fromLonLat([stop.lon, stop.lat]),
duration: 1000
},
// Display the popup once the animation finished
() => setPopupDisplayed(true)
);
}
} }
onMount(() => buildMap(mapDiv)); onMount(() => buildMap(mapDiv));
// Filling the map with stops shape
createEffect(() => { createEffect(() => {
/* TODO: Avoid to clear all layers... */ const stops = getFoundStops();
stopsLayerGroup.clearLayers(); const foundStopIds = new Set();
for (const foundStop of stops) {
foundStopIds.add(foundStop.id);
foundStop.stops.forEach(s => foundStopIds.add(s.id));
}
for (const stop of getFoundStops()) { for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) {
const markers = setMarker(stop); const stopId = parseInt(stopIdStr);
addMarkers(stop.id, markers); if (!foundStopIds.has(stopId)) {
for (const marker of markers) { console.log(`Remove feature for ${stopId}`);
stopsLayerGroup.addLayer(marker); stopVectorSource.removeFeature(feature);
delete displayedFeatures[stopId];
} }
} }
const stopsBound = stopsLayerGroup.getBounds(); const features = getAllMapFeatures();
if (map !== undefined && Object.keys(stopsBound).length) { for (const [stopIdStr, feature] of Object.entries(features)) {
map.fitBounds(stopsBound); const stopId = parseInt(stopIdStr);
if (foundStopIds.has(stopId) && !(stopId in displayedFeatures)) {
console.log(`Add feature for ${stopId}`);
stopVectorSource.addFeature(feature);
displayedFeatures[stopId] = feature;
}
}
const extend = stopVectorSource.getExtent();
if (map !== undefined && !isEmptyExtend(extend)) {
map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding });
} }
}); });
return <div ref={mapDiv} class="map" id="main-map" />; // Flashing effect
createEffect(() => {
const highlightedStopId = getHighlightedStop()?.id;
if (highlightedStopId !== undefined) {
const stop = getStop(highlightedStopId);
if (stop !== undefined) {
const stops = stop.stops ? stop.stops : [stop];
stops.forEach((s) => {
const feature = displayedFeatures[s.id];
if (feature !== undefined) {
flash(feature);
}
});
}
}
});
const flash = (feature: OlFeature) => {
const start = Date.now();
const flashGeom = feature.getGeometry()?.clone();
const listenerKey = stopVectorLayer.on('postrender', animate);
// Force postrender raising.
feature.changed();
function animate(event) {
const frameState = event.frameState;
const elapsed = frameState.time - start;
const vectorContext = getVectorContext(event);
if (elapsed >= flashDurationMs) {
unByKey(listenerKey);
return;
}
if (flashGeom !== undefined && map !== undefined) {
const elapsedRatio = elapsed / flashDurationMs;
// radius will be 5 at start and 30 at end.
const radius = easeOut(elapsedRatio) * 25 + 5;
const opacity = easeOut(1 - elapsedRatio);
const style = new Style({
image: new Circle({
radius: radius,
stroke: new Stroke({
color: `rgba(255, 0, 0, ${opacity})`,
width: 0.25 + opacity,
}),
}),
});
vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom);
// tell OpenLayers to continue postrender animation
map.render();
}
}
}
return <>
<div ref={mapDiv} class="map">
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} />
</div>
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For>
</>;
} }
const Footer: VoidComponent<{}> = () => { const Footer: VoidComponent<{}> = () => {
@@ -427,14 +762,14 @@ const Footer: VoidComponent<{}> = () => {
export const StopsSearchMenu: VoidComponent = () => { export const StopsSearchMenu: VoidComponent = () => {
const MAX_STOPS_PER_PANEL = 5; const maxStopsPerPanel = 5;
return ( return (
<div class="stopSearchMenu"> <div class="stopSearchMenu">
<SearchProvider> <SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} /> <Header title="Recherche de l'arrêt..." minCharsNb={4} />
<div class="body"> <div class="body">
<StopsPanels maxStopsPerPanel={MAX_STOPS_PER_PANEL} /> <StopsPanels maxStopsPerPanel={maxStopsPerPanel} />
<Map /> <Map />
</div> </div>
<Footer /> <Footer />

View File

@@ -63,6 +63,24 @@ export class Stop {
export type Stops = Record<number, Stop>; export type Stops = Record<number, Stop>;
export type Points = [number, number][];
export class StopShape {
stop_id: number;
type_: number;
bounding_box: number[];
points: Points;
constructor(stop_id: number, type_: number, bounding_box: number[], points: Points) {
this.stop_id = stop_id;
this.type_ = type_;
this.bounding_box = bounding_box;
this.points = points;
}
};
export type StopShapes = Record<number, StopShape>;
export class Line { export class Line {
id: string; id: string;
shortName: string; shortName: string;

View File

@@ -26,7 +26,7 @@ export const TransportModeWeights: Record<string, number> = {
export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined { export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined {
let ret = undefined; let ret = undefined;
if (validTransportModes.includes(mode)) { if (validTransportModes.includes(mode)) {
ret = `/public/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`; return `/carrramba-encore-rate/public/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
} }
return ret; return ret;
} }