Merge branch 'reduce-frontend-bundle-size' into develop

This commit is contained in:
2023-05-01 22:36:37 +02:00
21 changed files with 1277 additions and 1127 deletions

View File

@@ -12,10 +12,9 @@ from typing import (
from aiofiles import open as async_open from aiofiles import open as async_open
from aiohttp import ClientSession from aiohttp import ClientSession
from msgspec import ValidationError from msgspec import ValidationError
from msgspec.json import Decoder from msgspec.json import Decoder
from rich import print from pyproj import Transformer
from shapefile import Reader as ShapeFileReader, ShapeRecord from shapefile import Reader as ShapeFileReader, ShapeRecord
from ..db import Database from ..db import Database
@@ -64,6 +63,8 @@ class IdfmInterface:
self._http_headers = {"Accept": "application/json", "apikey": self._api_key} self._http_headers = {"Accept": "application/json", "apikey": self._api_key}
self._epsg2154_epsg3857_transformer = Transformer.from_crs(2154, 3857)
self._json_stops_decoder = Decoder(type=List[IdfmStop]) self._json_stops_decoder = Decoder(type=List[IdfmStop])
self._json_stop_areas_decoder = Decoder(type=List[IdfmStopArea]) self._json_stop_areas_decoder = Decoder(type=List[IdfmStopArea])
self._json_connection_areas_decoder = Decoder(type=List[IdfmConnectionArea]) self._json_connection_areas_decoder = Decoder(type=List[IdfmConnectionArea])
@@ -89,19 +90,19 @@ class IdfmInterface:
( (
StopShape, StopShape,
self._request_stop_shapes, self._request_stop_shapes,
IdfmInterface._format_idfm_stop_shapes, self._format_idfm_stop_shapes,
), ),
( (
ConnectionArea, ConnectionArea,
self._request_idfm_connection_areas, self._request_idfm_connection_areas,
IdfmInterface._format_idfm_connection_areas, self._format_idfm_connection_areas,
), ),
( (
StopArea, StopArea,
self._request_idfm_stop_areas, self._request_idfm_stop_areas,
IdfmInterface._format_idfm_stop_areas, self._format_idfm_stop_areas,
), ),
(Stop, self._request_idfm_stops, IdfmInterface._format_idfm_stops), (Stop, self._request_idfm_stops, self._format_idfm_stops),
) )
for model, get_method, format_method in STEPS: for model, get_method, format_method in STEPS:
@@ -391,23 +392,26 @@ class IdfmInterface:
return ret return ret
@staticmethod def _format_idfm_stops(self, *stops: IdfmStop) -> Iterable[Stop]:
def _format_idfm_stops(*stops: IdfmStop) -> Iterable[Stop]:
for stop in stops: for stop in stops:
fields = stop.fields fields = stop.fields
try: try:
created_ts = int(fields.arrcreated.timestamp()) # type: ignore created_ts = int(fields.arrcreated.timestamp()) # type: ignore
except AttributeError: except AttributeError:
created_ts = None created_ts = None
epsg3857_point = self._epsg2154_epsg3857_transformer.transform(
fields.arrxepsg2154, fields.arryepsg2154
)
yield Stop( yield Stop(
id=int(fields.arrid), id=int(fields.arrid),
name=fields.arrname, name=fields.arrname,
latitude=fields.arrgeopoint.lat, epsg3857_x=epsg3857_point[0],
longitude=fields.arrgeopoint.lon, epsg3857_y=epsg3857_point[1],
town_name=fields.arrtown, town_name=fields.arrtown,
postal_region=fields.arrpostalregion, postal_region=fields.arrpostalregion,
xepsg2154=fields.arrxepsg2154,
yepsg2154=fields.arryepsg2154,
transport_mode=TransportMode(fields.arrtype.value), transport_mode=TransportMode(fields.arrtype.value),
version=fields.arrversion, version=fields.arrversion,
created_ts=created_ts, created_ts=created_ts,
@@ -419,53 +423,76 @@ class IdfmInterface:
record_ts=int(stop.record_timestamp.timestamp()), record_ts=int(stop.record_timestamp.timestamp()),
) )
@staticmethod def _format_idfm_stop_areas(self, *stop_areas: IdfmStopArea) -> Iterable[StopArea]:
def _format_idfm_stop_areas(*stop_areas: IdfmStopArea) -> Iterable[StopArea]:
for stop_area in stop_areas: for stop_area in stop_areas:
fields = stop_area.fields fields = stop_area.fields
try: try:
created_ts = int(fields.zdacreated.timestamp()) # type: ignore created_ts = int(fields.zdacreated.timestamp()) # type: ignore
except AttributeError: except AttributeError:
created_ts = None created_ts = None
epsg3857_point = self._epsg2154_epsg3857_transformer.transform(
fields.zdaxepsg2154, fields.zdayepsg2154
)
yield StopArea( yield StopArea(
id=int(fields.zdaid), id=int(fields.zdaid),
name=fields.zdaname, name=fields.zdaname,
town_name=fields.zdatown, town_name=fields.zdatown,
postal_region=fields.zdapostalregion, postal_region=fields.zdapostalregion,
xepsg2154=fields.zdaxepsg2154, epsg3857_x=epsg3857_point[0],
yepsg2154=fields.zdayepsg2154, epsg3857_y=epsg3857_point[1],
type=StopAreaType(fields.zdatype.value), type=StopAreaType(fields.zdatype.value),
version=fields.zdaversion, version=fields.zdaversion,
created_ts=created_ts, created_ts=created_ts,
changed_ts=int(fields.zdachanged.timestamp()), changed_ts=int(fields.zdachanged.timestamp()),
) )
@staticmethod
def _format_idfm_connection_areas( def _format_idfm_connection_areas(
self,
*connection_areas: IdfmConnectionArea, *connection_areas: IdfmConnectionArea,
) -> Iterable[ConnectionArea]: ) -> Iterable[ConnectionArea]:
for connection_area in connection_areas: for connection_area in connection_areas:
epsg3857_point = self._epsg2154_epsg3857_transformer.transform(
connection_area.zdcxepsg2154, connection_area.zdcyepsg2154
)
yield ConnectionArea( yield ConnectionArea(
id=int(connection_area.zdcid), id=int(connection_area.zdcid),
name=connection_area.zdcname, name=connection_area.zdcname,
town_name=connection_area.zdctown, town_name=connection_area.zdctown,
postal_region=connection_area.zdcpostalregion, postal_region=connection_area.zdcpostalregion,
xepsg2154=connection_area.zdcxepsg2154, epsg3857_x=epsg3857_point[0],
yepsg2154=connection_area.zdcyepsg2154, epsg3857_y=epsg3857_point[1],
transport_mode=StopAreaType(connection_area.zdctype.value), transport_mode=StopAreaType(connection_area.zdctype.value),
version=connection_area.zdcversion, version=connection_area.zdcversion,
created_ts=int(connection_area.zdccreated.timestamp()), created_ts=int(connection_area.zdccreated.timestamp()),
changed_ts=int(connection_area.zdcchanged.timestamp()), changed_ts=int(connection_area.zdcchanged.timestamp()),
) )
@staticmethod def _format_idfm_stop_shapes(
def _format_idfm_stop_shapes(*shape_records: ShapeRecord) -> Iterable[StopShape]: self, *shape_records: ShapeRecord
) -> Iterable[StopShape]:
for shape_record in shape_records: for shape_record in shape_records:
epsg3857_points = [
self._epsg2154_epsg3857_transformer.transform(*point)
for point in shape_record.shape.points
]
bbox_it = iter(shape_record.shape.bbox)
epsg3857_bbox = [
self._epsg2154_epsg3857_transformer.transform(*point)
for point in zip(bbox_it, bbox_it)
]
yield StopShape( yield StopShape(
id=shape_record.record[1], id=shape_record.record[1],
type=shape_record.shape.shapeType, type=shape_record.shape.shapeType,
bounding_box=list(shape_record.shape.bbox), epsg3857_bbox=epsg3857_bbox,
points=shape_record.shape.points, epsg3857_points=epsg3857_points,
) )
async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]: async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]:
@@ -508,7 +535,7 @@ class IdfmInterface:
print("---------------------------------------------------------------------") print("---------------------------------------------------------------------")
return data return data
async def get_next_passages(self, stop_point_id: str) -> IdfmResponse | None: async def get_next_passages(self, stop_point_id: int) -> IdfmResponse | None:
ret = None ret = None
params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"} params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"}
async with ClientSession(headers=self._http_headers) as session: async with ClientSession(headers=self._http_headers) as session:

View File

@@ -48,8 +48,8 @@ class _Stop(Base):
name = mapped_column(String, nullable=False, index=True) name = mapped_column(String, nullable=False, index=True)
town_name = mapped_column(String, nullable=False) town_name = mapped_column(String, nullable=False)
postal_region = mapped_column(String, nullable=False) postal_region = mapped_column(String, nullable=False)
xepsg2154 = mapped_column(BigInteger, nullable=False) epsg3857_x = mapped_column(Float, nullable=False)
yepsg2154 = mapped_column(BigInteger, nullable=False) epsg3857_y = mapped_column(Float, nullable=False)
version = mapped_column(String, nullable=False) version = mapped_column(String, nullable=False)
created_ts = mapped_column(BigInteger) created_ts = mapped_column(BigInteger)
@@ -111,8 +111,6 @@ class Stop(_Stop):
id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True) id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
latitude = mapped_column(Float, nullable=False)
longitude = mapped_column(Float, nullable=False)
transport_mode = mapped_column(Enum(TransportMode), nullable=False) transport_mode = mapped_column(Enum(TransportMode), nullable=False)
accessibility = mapped_column(Enum(IdfmState), nullable=False) accessibility = mapped_column(Enum(IdfmState), nullable=False)
visual_signs_available = mapped_column(Enum(IdfmState), nullable=False) visual_signs_available = mapped_column(Enum(IdfmState), nullable=False)
@@ -191,8 +189,8 @@ class StopShape(Base):
id = mapped_column(BigInteger, primary_key=True) # Same id than ConnectionArea id = mapped_column(BigInteger, primary_key=True) # Same id than ConnectionArea
type = mapped_column(Integer, nullable=False) type = mapped_column(Integer, nullable=False)
bounding_box = mapped_column(JSON) epsg3857_bbox = mapped_column(JSON)
points = mapped_column(JSON) epsg3857_points = mapped_column(JSON)
__tablename__ = "stop_shapes" __tablename__ = "stop_shapes"
@@ -206,8 +204,8 @@ class ConnectionArea(Base):
name = mapped_column(String, nullable=False) name = mapped_column(String, nullable=False)
town_name = mapped_column(String, nullable=False) town_name = mapped_column(String, nullable=False)
postal_region = mapped_column(String, nullable=False) postal_region = mapped_column(String, nullable=False)
xepsg2154 = mapped_column(BigInteger, nullable=False) epsg3857_x = mapped_column(Float, nullable=False)
yepsg2154 = mapped_column(BigInteger, nullable=False) epsg3857_y = mapped_column(Float, nullable=False)
transport_mode = mapped_column(Enum(StopAreaType), nullable=False) transport_mode = mapped_column(Enum(StopAreaType), nullable=False)
version = mapped_column(String, nullable=False) version = mapped_column(String, nullable=False)

View File

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

View File

@@ -58,8 +58,6 @@ async def shutdown():
await db.disconnect() 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/" STATIC_ROOT = "../frontend/"
app.mount("/widget", StaticFiles(directory=STATIC_ROOT, html=True), name="widget") app.mount("/widget", StaticFiles(directory=STATIC_ROOT, html=True), name="widget")
@@ -94,22 +92,16 @@ async def get_line(line_id: str) -> LineSchema:
def _format_stop(stop: Stop) -> StopSchema: def _format_stop(stop: Stop) -> StopSchema:
# print(stop.__dict__)
return StopSchema( return StopSchema(
id=stop.id, id=stop.id,
name=stop.name, name=stop.name,
town=stop.town_name, town=stop.town_name,
# xepsg2154=stop.xepsg2154, epsg3857_x=stop.epsg3857_x,
# yepsg2154=stop.yepsg2154, epsg3857_y=stop.epsg3857_y,
lat=stop.latitude,
lon=stop.longitude,
lines=[line.id for line in stop.lines], lines=[line.id for line in stop.lines],
) )
# châtelet
@app.get("/stop/") @app.get("/stop/")
async def get_stop( async def get_stop(
name: str = "", limit: int = 10 name: str = "", limit: int = 10
@@ -142,8 +134,6 @@ async def get_stop(
id=stop_area.id, id=stop_area.id,
name=stop_area.name, name=stop_area.name,
town=stop_area.town_name, town=stop_area.town_name,
# xepsg2154=stop_area.xepsg2154,
# yepsg2154=stop_area.yepsg2154,
type=stop_area.type, type=stop_area.type,
lines=[line.id for line in stop_area.lines], lines=[line.id for line in stop_area.lines],
stops=formatted_stops, stops=formatted_stops,
@@ -187,8 +177,9 @@ async def get_next_passages(stop_id: str) -> NextPassagesSchema | None:
dst_names = call.DestinationDisplay dst_names = call.DestinationDisplay
dsts = [dst.value for dst in dst_names] if dst_names else [] dsts = [dst.value for dst in dst_names] if dst_names else []
arrivalPlatformName = (
print(f"{call.ArrivalPlatformName = }") call.ArrivalPlatformName.value if call.ArrivalPlatformName else None
)
next_passage = NextPassageSchema( next_passage = NextPassageSchema(
line=line_id, line=line_id,
@@ -197,9 +188,7 @@ async def get_next_passages(stop_id: str) -> NextPassagesSchema | None:
atStop=call.VehicleAtStop, atStop=call.VehicleAtStop,
aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime), aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime),
expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime), expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime),
arrivalPlatformName=call.ArrivalPlatformName.value arrivalPlatformName=arrivalPlatformName,
if call.ArrivalPlatformName
else None,
aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime), aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime),
expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime), expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime),
arrivalStatus=call.ArrivalStatus.value, arrivalStatus=call.ArrivalStatus.value,
@@ -250,7 +239,10 @@ async def get_stop_shape(stop_id: int) -> StopShapeSchema | None:
and (shape := await StopShape.get_by_id(connection_area.id)) is not None and (shape := await StopShape.get_by_id(connection_area.id)) is not None
): ):
return StopShapeSchema( return StopShapeSchema(
id=shape.id, type=shape.type, bbox=shape.bounding_box, points=shape.points id=shape.id,
type=shape.type,
epsg3857_bbox=shape.epsg3857_bbox,
epsg3857_points=shape.epsg3857_points,
) )
msg = f"No shape found for stop {stop_id}" msg = f"No shape found for stop {stop_id}"

View File

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

View File

@@ -7,12 +7,12 @@
"start": "vite", "start": "vite",
"dev": "vite --debug", "dev": "vite --debug",
"build": "vite build", "build": "vite build",
"serve": "vite preview" "serve": "vite preview",
"bundle-visualizer": "npx vite-bundle-visualizer"
}, },
"license": "MIT", "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",
@@ -20,10 +20,10 @@
"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",
"vite-bundle-visualizer": "^0.6.0",
"vite-plugin-solid": "^2.5.0" "vite-plugin-solid": "^2.5.0"
}, },
"dependencies": { "dependencies": {
"@hope-ui/solid": "^0.6.7",
"@motionone/solid": "^10.15.5", "@motionone/solid": "^10.15.5",
"@solid-primitives/date": "^2.0.5", "@solid-primitives/date": "^2.0.5",
"@solid-primitives/scroll": "^2.0.10", "@solid-primitives/scroll": "^2.0.10",
@@ -31,7 +31,6 @@
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"matrix-widget-api": "^1.1.1", "matrix-widget-api": "^1.1.1",
"ol": "^7.3.0", "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

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

View File

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

View File

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

View File

@@ -1,222 +0,0 @@
@use "_common";
@use "_utils";
.stopSearchMenu {
@extend %widget;
.inputGroup {
width: 50%;
// height: 5%;
// TODO: Setup hop-ui to avoid to have to overrride rules.
input {
color: var(--idfm-white);
font-family: IDFVoyageur-regular;
}
}
.title {
@extend %title;
display: flex;
justify-content: center;
}
.body {
@extend %body;
flex-direction: row;
.stopsPanels {
width: 50%;
height: 100%;
scroll-snap-type: y mandatory;
overflow-y: scroll;
.stopPanel {
scroll-snap-align: center;
.stop {
width: calc(1880/1920*100%);
height: calc(100% / 5);
// margin: 0 calc(20/1920*100%);
margin: 0 calc(10/1920*100%);
display: flex;
align-items: center;
flex-direction: row;
/* TODO: compute the border weight according to the parent height */
/* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px);
cursor: default;
.name {
margin-left: calc(40/1920*100%);
width: 60%;
aspect-ratio: 2.5;
display: flex;
align-items: center;
font-family: IDFVoyageur-bold;
}
.lineRepr {
width: 40%;
aspect-ratio: 2.5;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
.transportMode {
@extend %transportMode;
height: 50%;
}
.linesRepresentationMatrix {
@extend %busLinePicto; // Use the larger picto aspect-ratio
width: 75%;
aspect-ratio: 3;
display: flex;
flex-flow: row;
flex-wrap: wrap;
%picto {
margin-left: 1%;
align-self: center;
justify-self: center;
}
%singleLinePicto {
@extend %picto;
height: 80%;
}
.transportMode {
@extend %transportMode;
@extend %picto;
}
.tramLinePicto {
@extendnd %tramLinePicto;
@extend %singleLinePicto;
}
.trainLinePicto {
@extend %trainLinePicto;
@extend %singleLinePicto;
}
.metroLinePicto {
@extend %metroLinePicto;
@extend %singleLinePicto;
}
.busLinePicto {
@extend %busLinePicto;
@extend %picto;
height: 40%;
}
}
}
}
}
.displayed {
display: block;
}
}
.map {
position: relative;
height: 100%;
width: 50%;
.ol-viewport {
@extend %body;
position: absolute;
margin: 0;
}
.popup {
@extend %body;
margin: 0;
position: absolute;
width: 100%;
height: 35%;
border: solid var(--idfm-white) calc(0.2*1vh);
background-color: var(--idfm-black);
z-index: 1;
visibility: hidden;
.header {
@extend %header;
color: var(--idfm-white);
}
.body {
@extend %body;
scroll-snap-type: y mandatory;
overflow-y: scroll;
.line {
scroll-snap-align: center;
height: calc(100% / 3);
margin: 0 calc(10/1920*100%);
display: flex;
flex-direction: row;
align-items: center;
font-family: IDFVoyageur-bold;
.busLinePicto {
@extend %busLinePicto;
height: 80%;
width: 30%;
}
.name {
width: 100%;
height: 60%;
}
div {
height: 100%;
svg {
max-width: 100%;
max-height: 100%;
}
}
}
}
.footer {
@extend %footer;
}
}
.displayed {
visibility: visible;
}
}
}
}

View File

@@ -1,779 +0,0 @@
import { createContext, createEffect, createResource, createSignal, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js';
import { createStore } from "solid-js/store";
import { createScrollPosition } from "@solid-primitives/scroll";
import { Input, InputLeftAddon, InputGroup } from "@hope-ui/solid";
import 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 { AppContextContext, AppContextStore } from "./appContext";
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import "./stopsSearchMenu.scss";
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 {
getSearchText: () => string;
setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>;
getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void;
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
getHighlightedStop: () => Stop | undefined;
setHighlightedStop: (stop: Stop) => void;
resetHighlightedStop: () => void;
getMapFeature: (stopId: number) => OlFeature | undefined;
getAllMapFeatures: () => ByStopIdMapFeatures;
setMapFeature: (stopId: number, feature: OlFeature) => void;
};
const SearchContext = createContext<SearchStore>();
function SearchProvider(props: { children: JSX.Element }) {
const searchTextDelayMs = 1500;
type Store = {
searchText: string;
searchPromise: Promise<void> | undefined;
foundStops: Stop[];
displayedPanelId: number;
panels: PositionedPanel[];
highlightedStop: Stop | undefined;
mapFeatures: ByStopIdMapFeatures;
};
const [store, setStore] = createStore<Store>({
searchText: "",
searchPromise: undefined,
foundStops: [],
displayedPanelId: 0,
panels: [],
highlightedStop: undefined,
mapFeatures: {},
});
const getSearchText = (): string => {
return store.searchText;
}
const debounce = async (fn: (...args: any[]) => Promise<void>, delayMs: number) => {
let timerId: number;
return new Promise((...args) => {
clearTimeout(timerId);
timerId = setTimeout(fn, delayMs, ...args);
});
}
const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => {
setStore('searchText', text);
if (store.searchPromise === undefined) {
const { searchStopByName } = businessDataStore;
const promise: Promise<void> = debounce(async (onSuccess: () => void) => {
console.log(`Fetching data for "${store.searchText}" stop name`);
const stopsById = await searchStopByName(store.searchText);
console.log("stopsById=", stopsById);
setFoundStops(Object.values(stopsById));
onSuccess();
}, searchTextDelayMs).then(() => {
setStore('searchPromise', undefined);
});
setStore('searchPromise', promise);
}
}
const getFoundStops = (): Stop[] => {
return store.foundStops;
}
const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops);
}
const getDisplayedPanelId = (): number => {
return store.displayedPanelId;
}
const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId);
}
const getPanels = (): PositionedPanel[] => {
return store.panels;
}
const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels);
}
const getHighlightedStop = (): Stop | undefined => {
return store.highlightedStop;
}
const setHighlightedStop = (stop: Stop): void => {
setStore('highlightedStop', stop);
}
const resetHighlightedStop = (): void => {
setStore('highlightedStop', undefined);
}
const getAllMapFeatures = (): ByStopIdMapFeatures => {
return store.mapFeatures;
}
const getMapFeature = (stopId: number): OlFeature | undefined => {
return store.mapFeatures[stopId];
}
const setMapFeature = (stopId: number, feature: OlFeature): void => {
setStore('mapFeatures', stopId, feature);
};
return (
<SearchContext.Provider value={{
getSearchText, setSearchText,
getFoundStops, setFoundStops,
getDisplayedPanelId, setDisplayedPanelId,
getPanels, setPanels,
getHighlightedStop, setHighlightedStop, resetHighlightedStop,
getMapFeature, getAllMapFeatures, setMapFeature,
}}>
{props.children}
</SearchContext.Provider>
);
}
const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { setSearchText } = searchStore;
const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => {
/* TODO: Add a tempo before fetching stop for giving time to user to finish his request */
const stopName = event.currentTarget.value;
if (stopName.length >= props.minCharsNb) {
await setSearchText(stopName, businessDataStore);
}
}
return (
<div class="header">
<div class="title">
<svg viewBox="0 0 1260 50">
<text x="0" y="50%" dominant-baseline="middle" font-size="50" style="fill: #ffffff">
{props.title}
</text>
</svg>
</div>
<div class="inputGroup">
<InputGroup >
<InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={onStopNameInput} placeholder="Stop name..." />
</InputGroup>
</div>
</div >
);
};
const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 40;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const { getLine } = businessDataStore;
const fetchLinesRepr = async (lineIds: string[]): Promise<JSX.Element[]> => {
const reprs = [];
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
reprs.push(<div class="transportMode">{renderLineTransportMode(line)}</div>);
reprs.push(renderLinePicto(line));
}
}
return reprs;
}
const [lineReprs] = createResource<JSX.Element[], string[]>(props.stop.lines, fetchLinesRepr);
return (
<div class="stop">
<svg class="name" viewBox={`0 0 215 ${fontSize}`}>
<text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={fontSize}>
{props.stop.name}
</text>
</svg>
<For each={lineReprs()}>{(line: JSX.Element) => line}</For>
</div>
);
}
type ByTransportModeReprs = {
mode: JSX.Element | undefined;
lines: Record<string, JSX.Element | JSX.Element[] | undefined>;
}
const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 10;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || appContextStore === undefined || searchStore === undefined)
return <div />;
const { getLine } = businessDataStore;
const { setDisplayedStops } = appContextStore;
const { setHighlightedStop, resetHighlightedStop } = searchStore;
const fetchLinesRepr = async (stop: Stop): Promise<JSX.Element> => {
const lineIds = new Set(stop.lines);
const stops = stop.stops;
for (const stop of stops) {
stop.lines.forEach(lineIds.add, lineIds);
}
const byModeReprs: Record<string, ByTransportModeReprs> = {};
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
if (!(line.transportMode in byModeReprs)) {
byModeReprs[line.transportMode] = {
mode: <div class="transportMode">{renderLineTransportMode(line)}</div>,
lines: {}
};
}
byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line);
}
}
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] <
TransportModeWeights[y] ? 1 : -1);
return (
<div class="lineRepr">
<For each={sortedTransportModes}>{(transportMode) => {
const reprs = byModeReprs[transportMode];
const lineNames = Object.keys(reprs.lines).sort((x, y) => x.localeCompare(y));
return <>
{reprs.mode}
<div class="linesRepresentationMatrix">
<For each={lineNames}>{(lineName) => reprs.lines[lineName]}</For>
</div>
</>
}}
</For>
</div >
);
}
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return (
<div
class="stop"
onClick={() => setDisplayedStops([props.stop])}
onMouseEnter={() => setHighlightedStop(props.stop)}
onMouseLeave={resetHighlightedStop}
>
<div class="name" >
<ScrollingText height={fontSize} width={100} content={props.stop.name} />
</div>
{lineReprs()}
</div>
);
}
const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => {
return (
<div classList={{ "stopPanel": true, "displayed": props.show }}>
<For each={props.stops.sort((x, y) => x.name.localeCompare(y.name))}>
{(stop) => {
return <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
<StopAreaRepr stop={stop} />
</Show>;
}}
</For>
</div>
);
}
const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore;
let stopsPanelsRef: HTMLDivElement | undefined = undefined
const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef);
const yStopsPanelsScroll = () => stopsPanelsScroll.y;
createEffect(() => {
yStopsPanelsScroll();
for (const panel of getPanels()) {
const panelDiv = panel.panel();
const panelDivClientRect = panelDiv.getBoundingClientRect();
if (panelDivClientRect.y > 0) {
setDisplayedPanelId(panel.position);
break;
}
}
});
return (
<div ref={stopsPanelsRef} class="stopsPanels">
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionedPanel[] = [];
let stops: Stop[] = [];
for (const stop of getFoundStops()) {
if (stops.length < props.maxStopsPerPanel) {
stops.push(stop);
}
else {
const panelId = newPanels.length;
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
stops = [stop];
}
}
if (stops.length) {
const panelId = newPanels.length;
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
);
}
const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const { getLine, getStopDestinations } = businessDataStore;
let popupDiv: HTMLDivElement | undefined = undefined;
const getDestinations = async (stop: Stop): Promise<{ lineId: string, destinations: string[] }[]> => {
let ret = [];
if (stop !== undefined) {
const result = await getStopDestinations(stop.id);
for (const [lineId, destinations] of Object.entries(result)) {
const line = await getLine(lineId);
const linePicto = renderLinePicto(line);
ret.push({ lineId: linePicto, destinations: destinations });
}
}
return ret;
}
const [destinations] = createResource(() => props.stop, getDestinations);
return (
<div ref={popupDiv} classList={{ "popup": true, "displayed": props.show }}>
<div class="header">{props.stop?.name}</div>
<div class="body">
<For each={destinations()}>
{(dst) => {
return <div class='line'>
{dst.lineId}
<div class="name">
<ScrollingText height={10} width={130} content={dst.destinations.join('/')} />
</div>
</div>;
}}
</For>
</div>
</div >
);
}
// 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 {
geometry = new OlPoint(fromLonLat([props.stop.lon, props.stop.lat]));
}
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);
}
}
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));
// Filling the map with stops shape
createEffect(() => {
const stops = getFoundStops();
const foundStopIds = new Set();
for (const foundStop of stops) {
foundStopIds.add(foundStop.id);
foundStop.stops.forEach(s => foundStopIds.add(s.id));
}
for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) {
const stopId = parseInt(stopIdStr);
if (!foundStopIds.has(stopId)) {
console.log(`Remove feature for ${stopId}`);
stopVectorSource.removeFeature(feature);
delete displayedFeatures[stopId];
}
}
const features = getAllMapFeatures();
for (const [stopIdStr, feature] of Object.entries(features)) {
const stopId = parseInt(stopIdStr);
if (foundStopIds.has(stopId) && !(stopId in displayedFeatures)) {
console.log(`Add feature for ${stopId}`);
stopVectorSource.addFeature(feature);
displayedFeatures[stopId] = feature;
}
}
const extend = stopVectorSource.getExtent();
if (map !== undefined && !isEmptyExtend(extend)) {
map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding });
}
});
// Flashing effect
createEffect(() => {
const highlightedStopId = getHighlightedStop()?.id;
if (highlightedStopId !== undefined) {
const stop = getStop(highlightedStopId);
if (stop !== undefined) {
const stops = stop.stops ? stop.stops : [stop];
stops.forEach((s) => {
const feature = displayedFeatures[s.id];
if (feature !== undefined) {
flash(feature);
}
});
}
}
});
const flash = (feature: OlFeature) => {
const start = Date.now();
const flashGeom = feature.getGeometry()?.clone();
const listenerKey = stopVectorLayer.on('postrender', animate);
// Force postrender raising.
feature.changed();
function animate(event) {
const frameState = event.frameState;
const elapsed = frameState.time - start;
const vectorContext = getVectorContext(event);
if (elapsed >= flashDurationMs) {
unByKey(listenerKey);
return;
}
if (flashGeom !== undefined && map !== undefined) {
const elapsedRatio = elapsed / flashDurationMs;
// radius will be 5 at start and 30 at end.
const radius = easeOut(elapsedRatio) * 25 + 5;
const opacity = easeOut(1 - elapsedRatio);
const style = new Style({
image: new Circle({
radius: radius,
stroke: new Stroke({
color: `rgba(255, 0, 0, ${opacity})`,
width: 0.25 + opacity,
}),
}),
});
vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom);
// tell OpenLayers to continue postrender animation
map.render();
}
}
}
return <>
<div ref={mapDiv} class="map">
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} />
</div>
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For>
</>;
}
const Footer: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { getDisplayedPanelId, getPanels } = searchStore;
return (
<div class="footer">
<For each={getPanels()}>
{(panel) => {
const position = panel.position;
return (
<div>
<svg viewBox="0 0 29 29">
<circle cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3"
style={{ fill: `var(--idfm-${position == getDisplayedPanelId() ? "white" : "black"})` }}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}
export const StopsSearchMenu: VoidComponent = () => {
const maxStopsPerPanel = 5;
return (
<div class="stopSearchMenu">
<SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} />
<div class="body">
<StopsPanels maxStopsPerPanel={maxStopsPerPanel} />
<Map />
</div>
<Footer />
</SearchProvider>
</div>
);
};

View File

@@ -0,0 +1,87 @@
@use "../_common";
@use "../_utils";
.map {
position: relative;
height: 100%;
width: 50%;
.ol-viewport {
@extend %body;
position: absolute;
margin: 0;
}
.popup {
@extend %body;
margin: 0;
position: absolute;
width: 100%;
height: 35%;
border: solid var(--idfm-white) calc(0.2*1vh);
background-color: var(--idfm-black);
z-index: 1;
visibility: hidden;
.header {
@extend %header;
color: var(--idfm-white);
}
.body {
@extend %body;
scroll-snap-type: y mandatory;
overflow-y: scroll;
.line {
scroll-snap-align: center;
height: calc(100% / 3);
margin: 0 calc(10/1920*100%);
display: flex;
flex-direction: row;
align-items: center;
font-family: IDFVoyageur-bold;
.busLinePicto {
@extend %busLinePicto;
height: 80%;
width: 30%;
}
.name {
width: 100%;
height: 60%;
}
div {
height: 100%;
svg {
max-width: 100%;
max-height: 100%;
}
}
}
}
.footer {
@extend %footer;
}
}
.displayed {
visibility: visible;
}
}

View File

@@ -0,0 +1,213 @@
import { createEffect, createSignal, For, onMount, ParentComponent, useContext } from 'solid-js';
import OlFeature from 'ol/Feature';
import OlMap from 'ol/Map';
import OlView from 'ol/View';
import { isEmpty as isEmptyExtend } from 'ol/extent';
import { FeatureLike as OlFeatureLike } from 'ol/Feature';
import OlOSM from 'ol/source/OSM';
import OlOverlay from 'ol/Overlay';
import OlVectorSource from 'ol/source/Vector';
import { Tile as OlTileLayer, Vector as OlVectorLayer } from 'ol/layer';
import { Circle, Stroke, Style } from 'ol/style';
import { easeOut } from 'ol/easing';
import { getVectorContext } from 'ol/render';
import { unByKey } from 'ol/Observable';
import { Stop } from '../types';
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { SearchContext, SearchStore } from "./searchStore";
import { MapStop } from "./mapStop";
import { StopPopup } from "./stopPopup";
import "./map.scss";
export const Map: ParentComponent<{}> = () => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { getStop } = businessDataStore;
const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore;
const [selectedMapStop, setSelectedMapStop] = createSignal<Stop | undefined>(undefined);
const [isPopupDisplayed, setPopupDisplayed] = createSignal<boolean>(false);
const mapCenter = [260769.80336542107, 6250587.867330259]; // EPSG:3857
const fitDurationMs = 1500;
const flashDurationMs = 2000;
// TODO: Set padding according to the marker design.
const fitPointsPadding = [50, 50, 50, 50];
let mapDiv: HTMLDivElement | undefined = undefined;
let popup: StopPopup | undefined = undefined;
const stopVectorSource = new OlVectorSource({ features: [] });
const stopVectorLayer = new OlVectorLayer({ source: stopVectorSource });
let overlay: OlOverlay | undefined = undefined;
let map: OlMap | undefined = undefined;
const displayedFeatures: Record<number, OlFeature> = {};
const buildMap = (div: HTMLDivElement): void => {
overlay = new OlOverlay({
element: popup,
autoPan: {
animation: {
duration: 250,
},
},
});
map = new OlMap({
target: div,
controls: [], // remove controls
view: new OlView({
center: mapCenter,
zoom: 10,
}),
layers: [
new OlTileLayer({
source: new OlOSM(),
}),
stopVectorLayer,
],
overlays: [overlay],
});
map.on('singleclick', onClickedMap);
}
const onClickedMap = async (event): Promise<void> => {
const features = await stopVectorLayer.getFeatures(event.pixel);
// Handle only the first feature
if (features.length > 0) {
await onClickedFeature(features[0]);
}
else {
setPopupDisplayed(false);
setSelectedMapStop(undefined);
}
}
const onClickedFeature = async (feature: OlFeatureLike): Promise<void> => {
const stopId: number = feature.getId();
const stop = getStop(stopId);
// TODO: Handle StopArea (use center given by the backend)
if (stop?.epsg3857_x !== undefined && stop?.epsg3857_y !== undefined) {
setSelectedMapStop(stop);
map?.getView().animate(
{
center: [stop.epsg3857_x, stop.epsg3857_y],
duration: 1000
},
// Display the popup once the animation finished
() => setPopupDisplayed(true)
);
}
}
onMount(() => buildMap(mapDiv));
// Filling the map with stops shape
createEffect(() => {
const stops = getFoundStops();
const foundStopIds = new Set();
for (const foundStop of stops) {
foundStopIds.add(foundStop.id);
foundStop.stops.forEach(s => foundStopIds.add(s.id));
}
for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) {
const stopId = parseInt(stopIdStr);
if (!foundStopIds.has(stopId)) {
console.log(`Remove feature for ${stopId}`);
stopVectorSource.removeFeature(feature);
delete displayedFeatures[stopId];
}
}
const features = getAllMapFeatures();
for (const [stopIdStr, feature] of Object.entries(features)) {
const stopId = parseInt(stopIdStr);
if (foundStopIds.has(stopId) && !(stopId in displayedFeatures)) {
console.log(`Add feature for ${stopId}`);
stopVectorSource.addFeature(feature);
displayedFeatures[stopId] = feature;
}
}
const extend = stopVectorSource.getExtent();
if (map !== undefined && !isEmptyExtend(extend)) {
map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding });
}
});
// Flashing effect
createEffect(() => {
const highlightedStopId = getHighlightedStop()?.id;
if (highlightedStopId !== undefined) {
const stop = getStop(highlightedStopId);
if (stop !== undefined) {
const stops = stop.stops ? stop.stops : [stop];
stops.forEach((s) => {
const feature = displayedFeatures[s.id];
if (feature !== undefined) {
flash(feature);
}
});
}
}
});
const flash = (feature: OlFeature) => {
const start = Date.now();
const flashGeom = feature.getGeometry()?.clone();
const listenerKey = stopVectorLayer.on('postrender', animate);
// Force postrender raising.
feature.changed();
function animate(event) {
const frameState = event.frameState;
const elapsed = frameState.time - start;
const vectorContext = getVectorContext(event);
if (elapsed >= flashDurationMs) {
unByKey(listenerKey);
return;
}
if (flashGeom !== undefined && map !== undefined) {
const elapsedRatio = elapsed / flashDurationMs;
// radius will be 5 at start and 30 at end.
const radius = easeOut(elapsedRatio) * 25 + 5;
const opacity = easeOut(1 - elapsedRatio);
const style = new Style({
image: new Circle({
radius: radius,
stroke: new Stroke({
color: `rgba(255, 0, 0, ${opacity})`,
width: 0.25 + opacity,
}),
}),
});
vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom);
// tell OpenLayers to continue postrender animation
map.render();
}
}
}
return <>
<div ref={mapDiv} class="map">
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} />
</div>
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For>
</>;
}

View File

@@ -0,0 +1,85 @@
import { createEffect, createResource, For, useContext, VoidComponent } from 'solid-js';
import { Circle, Fill, Stroke, Style } from 'ol/style';
import OlFeature from 'ol/Feature';
import OlPoint from 'ol/geom/Point';
import OlPolygon from 'ol/geom/Polygon';
import { Stop, StopShape } from '../types';
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { SearchContext, SearchStore } from "./searchStore";
// TODO: Use boolean to set MapStop selected
export const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { getStopShape } = businessDataStore;
const { setMapFeature } = searchStore;
const stopStyle = new Style({
image: new Circle({
fill: undefined,
stroke: new Stroke({ color: '#3399CC', width: 1.5 }),
radius: 10,
}),
});
const selectedStopStyle = new Style({
image: new Circle({
fill: undefined,
stroke: new Stroke({ color: 'purple', width: 2 }),
radius: 10,
}),
});
const stopAreaStyle = new Style({
stroke: new Stroke({ color: 'red' }),
fill: new Fill({ color: 'rgba(255,255,255,0.2)' }),
});
const getShape = async (stopId: number): Promise<StopShape | undefined> => {
return await getStopShape(stopId);
};
const [shape] = createResource<StopShape | undefined, number>(props.stop.id, getShape);
createEffect(() => {
const shape_ = shape();
if (shape_ === undefined) {
return;
}
let feature = undefined;
if (props.stop.epsg3857_x !== undefined && props.stop.epsg3857_y !== undefined) {
const selectStopStyle = () => {
return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle);
}
feature = new OlFeature({
geometry: new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]),
});
feature.setStyle(selectStopStyle);
}
else {
let geometry = undefined;
const areaShape = shape();
if (areaShape !== undefined) {
geometry = new OlPolygon([areaShape.epsg3857_points.slice(0, -1)]);
}
else {
geometry = new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]);
}
feature = new OlFeature({ geometry: geometry });
feature.setStyle(stopAreaStyle);
}
feature.setId(props.stop.id);
setMapFeature(props.stop.id, feature);
});
return <For each={props.stop.stops}>{stop => <MapStop stop={stop} selected={props.selected} />}</For>;
}

View File

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

View File

@@ -0,0 +1,100 @@
@use "../_common";
@use "../_utils";
.stopPanel {
scroll-snap-align: center;
.stop {
width: calc(1880/1920*100%);
height: calc(100% / 5);
// margin: 0 calc(20/1920*100%);
margin: 0 calc(10/1920*100%);
display: flex;
align-items: center;
flex-direction: row;
/* TODO: compute the border weight according to the parent height */
/* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px);
cursor: default;
.name {
margin-left: calc(40/1920*100%);
width: 60%;
aspect-ratio: 2.5;
display: flex;
align-items: center;
font-family: IDFVoyageur-bold;
}
.lineRepr {
width: 40%;
aspect-ratio: 2.5;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
.transportMode {
@extend %transportMode;
height: 50%;
}
.linesRepresentationMatrix {
@extend %busLinePicto; // Use the larger picto aspect-ratio
width: 75%;
aspect-ratio: 3;
display: flex;
flex-flow: row;
flex-wrap: wrap;
%picto {
margin-left: 1%;
align-self: center;
justify-self: center;
}
%singleLinePicto {
@extend %picto;
height: 80%;
}
.transportMode {
@extend %transportMode;
@extend %picto;
}
.tramLinePicto {
@extend %tramLinePicto;
@extend %singleLinePicto;
}
.trainLinePicto {
@extend %trainLinePicto;
@extend %singleLinePicto;
}
.metroLinePicto {
@extend %metroLinePicto;
@extend %singleLinePicto;
}
.busLinePicto {
@extend %busLinePicto;
@extend %picto;
height: 40%;
}
}
}
}
}

View File

@@ -0,0 +1,143 @@
import { createResource, For, JSX, ParentComponent, Show, useContext, VoidComponent } from 'solid-js';
import { Stop } from '../types';
import { renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from '../utils';
import { AppContextContext, AppContextStore } from "../appContext";
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { SearchContext, SearchStore } from "./searchStore";
import "./stopPanel.scss";
const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 40;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const { getLine } = businessDataStore;
const fetchLinesRepr = async (lineIds: string[]): Promise<JSX.Element[]> => {
const reprs = [];
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
reprs.push(<div class="transportMode">{renderLineTransportMode(line)}</div>);
reprs.push(renderLinePicto(line));
}
}
return reprs;
}
const [lineReprs] = createResource<JSX.Element[], string[]>(props.stop.lines, fetchLinesRepr);
return (
<div class="stop">
<svg class="name" viewBox={`0 0 215 ${fontSize}`}>
<text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={fontSize}>
{props.stop.name}
</text>
</svg>
<For each={lineReprs()}>{(line: JSX.Element) => line}</For>
</div>
);
}
type ByTransportModeReprs = {
mode: JSX.Element | undefined;
lines: Record<string, JSX.Element | JSX.Element[] | undefined>;
}
const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 10;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || appContextStore === undefined || searchStore === undefined)
return <div />;
const { getLine } = businessDataStore;
const { setDisplayedStops } = appContextStore;
const { setHighlightedStop, resetHighlightedStop } = searchStore;
const fetchLinesRepr = async (stop: Stop): Promise<JSX.Element> => {
const lineIds = new Set(stop.lines);
const stops = stop.stops;
for (const stop of stops) {
stop.lines.forEach(lineIds.add, lineIds);
}
const byModeReprs: Record<string, ByTransportModeReprs> = {};
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
if (!(line.transportMode in byModeReprs)) {
byModeReprs[line.transportMode] = {
mode: <div class="transportMode">{renderLineTransportMode(line)}</div>,
lines: {}
};
}
byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line);
}
}
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] <
TransportModeWeights[y] ? 1 : -1);
return (
<div class="lineRepr">
<For each={sortedTransportModes}>{(transportMode) => {
const reprs = byModeReprs[transportMode];
const lineNames = Object.keys(reprs.lines).sort((x, y) => x.localeCompare(y));
return <>
{reprs.mode}
<div class="linesRepresentationMatrix">
<For each={lineNames}>{(lineName) => reprs.lines[lineName]}</For>
</div>
</>
}}
</For>
</div >
);
}
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return (
<div
class="stop"
onClick={() => setDisplayedStops([props.stop])}
onMouseEnter={() => setHighlightedStop(props.stop)}
onMouseLeave={resetHighlightedStop}
>
<div class="name" >
<ScrollingText height={fontSize} width={100} content={props.stop.name} />
</div>
{lineReprs()}
</div>
);
}
export const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => {
return (
<div classList={{ "stopPanel": true, "displayed": props.show }}>
<For each={props.stops.sort((x, y) => x.name.localeCompare(y.name))}>
{(stop) => {
return <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
<StopAreaRepr stop={stop} />
</Show>;
}}
</For>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { createResource, For, ParentComponent, useContext } from 'solid-js';
import { Stop } from '../types';
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { renderLinePicto, ScrollingText } from '../utils';
export const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const { getLine, getStopDestinations } = businessDataStore;
let popupDiv: HTMLDivElement | undefined = undefined;
const getDestinations = async (stop: Stop): Promise<{ lineId: string, destinations: string[] }[]> => {
let ret = [];
if (stop !== undefined) {
const result = await getStopDestinations(stop.id);
for (const [lineId, destinations] of Object.entries(result)) {
const line = await getLine(lineId);
const linePicto = renderLinePicto(line);
ret.push({ lineId: linePicto, destinations: destinations });
}
}
return ret;
}
const [destinations] = createResource(() => props.stop, getDestinations);
return (
<div ref={popupDiv} classList={{ "popup": true, "displayed": props.show }}>
<div class="header">{props.stop?.name}</div>
<div class="body">
<For each={destinations()}>
{(dst) => {
return <div class='line'>
{dst.lineId}
<div class="name">
<ScrollingText height={10} width={130} content={dst.destinations.join('/')} />
</div>
</div>;
}}
</For>
</div>
</div >
);
}

View File

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

View File

@@ -0,0 +1,200 @@
import { createEffect, For, JSX, lazy, ParentComponent, useContext, Show, VoidComponent } from 'solid-js';
import { lazily } from 'solidjs-lazily';
import { createScrollPosition } from "@solid-primitives/scroll";
import { Stop } from '../types';
import { PositionedPanel } from '../utils';
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { SearchContext, SearchProvider, SearchStore } from "./searchStore";
import { StopsPanel } from "./stopPanel";
const { Map } = lazily(() => import("./map"));
import "./stopsSearchMenu.scss";
const StopNameInput: VoidComponent<{ onInput: JSX.EventHandler<HTMLInputElement, InputEvent>, leftAddon: string, placeholder: string }> = (props) => {
return (
<div class="stopNameInput">
<div class="leftAddon">{props.leftAddon}</div>
<input type="text" oninput={props.onInput} placeholder={props.placeholder} />
</div>);
};
const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { setSearchText } = searchStore;
const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => {
const stopName = event.currentTarget.value;
if (stopName.length >= props.minCharsNb) {
await setSearchText(stopName, businessDataStore);
}
}
return (
<div class="header">
<div class="title">
<svg viewBox="0 0 1260 50">
<text x="0" y="50%" dominant-baseline="middle" font-size="50" style="fill: #ffffff">
{props.title}
</text>
</svg>
</div>
<StopNameInput onInput={onStopNameInput} leftAddon="🚉 🚏" placeholder="Stop name..." />
</div >
);
};
const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore;
let stopsPanelsRef: HTMLDivElement | undefined = undefined
const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef);
const yStopsPanelsScroll = () => stopsPanelsScroll.y;
createEffect(() => {
yStopsPanelsScroll();
for (const panel of getPanels()) {
const panelDiv = panel.panel();
const panelDivClientRect = panelDiv.getBoundingClientRect();
if (panelDivClientRect.y > 0) {
setDisplayedPanelId(panel.position);
break;
}
}
});
return (
<div ref={stopsPanelsRef} class="stopsPanels">
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionedPanel[] = [];
let stops: Stop[] = [];
for (const stop of getFoundStops()) {
if (stops.length < props.maxStopsPerPanel) {
stops.push(stop);
}
else {
const panelId = newPanels.length;
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
stops = [stop];
}
}
if (stops.length) {
const panelId = newPanels.length;
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
);
};
const MapPlaceholder: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { enableMap } = searchStore;
const onDoubleClick = (): void => {
console.log('!!! ON DOUBLE CLICK');
enableMap(true);
}
return <div
class="mapPlaceholder" ondblclick={() => onDoubleClick()}>
Double-clic pour activer la carte
</div>;
};
const Body: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { isMapEnabled } = searchStore;
const maxStopsPerPanel = 5;
return <div class="body">
<StopsPanels maxStopsPerPanel={maxStopsPerPanel} />
<Show when={isMapEnabled()} fallback={<MapPlaceholder />}>
<Map />
</Show>
</div>;
};
const Footer: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { getDisplayedPanelId, getPanels } = searchStore;
return (
<div class="footer">
<For each={getPanels()}>
{(panel) => {
const position = panel.position;
return (
<div>
<svg viewBox="0 0 29 29">
<circle cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3"
style={{ fill: `var(--idfm-${position == getDisplayedPanelId() ? "white" : "black"})` }}
/>
</svg>
</div>
);
}}
</For>
</div>
);
};
export const StopsSearchMenu: VoidComponent = () => {
return (
<div class="stopSearchMenu">
<SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} />
<Body />
<Footer />
</SearchProvider>
</div>
);
};

View File

@@ -42,17 +42,17 @@ export class Stop {
id: number; id: number;
name: string; name: string;
town: string; town: string;
lat: number; epsg3857_x: number;
lon: number; epsg3857_y: number;
stops: Stop[]; stops: Stop[];
lines: string[]; lines: string[];
constructor(id: number, name: string, town: string, lat: number, lon: number, stops: Stop[], lines: string[]) { constructor(id: number, name: string, town: string, epsg3857_x: number, epsg3857_y: number, stops: Stop[], lines: string[]) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.town = town; this.town = town;
this.lat = lat; this.epsg3857_x = epsg3857_x;
this.lon = lon; this.epsg3857_y = epsg3857_y;
this.stops = stops; this.stops = stops;
this.lines = lines; this.lines = lines;
for (const stop of this.stops) { for (const stop of this.stops) {
@@ -68,14 +68,14 @@ export type Points = [number, number][];
export class StopShape { export class StopShape {
stop_id: number; stop_id: number;
type_: number; type_: number;
bounding_box: number[]; epsg3857_bbox: number[];
points: Points; epsg3857_points: Points;
constructor(stop_id: number, type_: number, bounding_box: number[], points: Points) { constructor(stop_id: number, type_: number, epsg3857_bbox: number[], epsg3857_points: Points) {
this.stop_id = stop_id; this.stop_id = stop_id;
this.type_ = type_; this.type_ = type_;
this.bounding_box = bounding_box; this.epsg3857_bbox = epsg3857_bbox;
this.points = points; this.epsg3857_points = epsg3857_points;
} }
}; };