6 Commits

12 changed files with 219 additions and 178 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

@@ -1,38 +1,37 @@
{ {
"name": "vite-template-solid", "name": "vite-template-solid",
"version": "0.0.0", "version": "0.0.0",
"engine": "19.3.0", "engine": "19.3.0",
"description": "", "description": "",
"scripts": { "scripts": {
"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", },
"devDependencies": { "license": "MIT",
"@types/leaflet": "^1.9.0", "devDependencies": {
"@types/proj4": "^2.5.2", "@types/leaflet": "^1.9.0",
"@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", "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",
"vite-plugin-solid": "^2.5.0" "vite-bundle-visualizer": "^0.6.0",
}, "vite-plugin-solid": "^2.5.0"
"dependencies": { },
"@hope-ui/solid": "^0.6.7", "dependencies": {
"@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",
"@stitches/core": "^1.2.8", "@stitches/core": "^1.2.8",
"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 fill="currentColor"
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 fill-rule="evenodd"
13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z" clip-rule="evenodd"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
/> />
), </svg>);
}); }

View File

@@ -21,18 +21,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

@@ -4,17 +4,41 @@
.stopSearchMenu { .stopSearchMenu {
@extend %widget; @extend %widget;
.inputGroup { .stopNameInput {
width: 50%; width: 50%;
// height: 5%; height: 60%;
display: flex;
flex-flow: row;
border: solid var(--neutral-color) calc(0.01*1vh);
border-radius: var(--border-radius);
background-color: transparent;
.leftAddon {
width: 17%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--idfm-white);
}
// TODO: Setup hop-ui to avoid to have to overrride rules.
input { input {
width: 83%;
padding-left: 3%;
padding-right: 3%;
color: var(--idfm-white); color: var(--idfm-white);
font-family: IDFVoyageur-regular; font-family: IDFVoyageur-regular;
background-color: transparent;
} }
} }
.title { .title {
@extend %title; @extend %title;

View File

@@ -2,8 +2,6 @@ import { createContext, createEffect, createResource, createSignal, For, JSX, on
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 OlFeature from 'ol/Feature'; import OlFeature from 'ol/Feature';
import OlMap from 'ol/Map'; import OlMap from 'ol/Map';
import OlView from 'ol/View'; import OlView from 'ol/View';
@@ -14,36 +12,26 @@ import OlOverlay from 'ol/Overlay';
import OlPoint from 'ol/geom/Point'; import OlPoint from 'ol/geom/Point';
import OlPolygon from 'ol/geom/Polygon'; import OlPolygon from 'ol/geom/Polygon';
import OlVectorSource from 'ol/source/Vector'; import OlVectorSource from 'ol/source/Vector';
import { fromLonLat, toLonLat } from 'ol/proj';
import { Tile as OlTileLayer, Vector as OlVectorLayer } from 'ol/layer'; import { Tile as OlTileLayer, Vector as OlVectorLayer } from 'ol/layer';
import { Circle, Fill, Stroke, Style } from 'ol/style'; import { Circle, Fill, Stroke, Style } from 'ol/style';
import { easeOut } from 'ol/easing'; import { easeOut } from 'ol/easing';
import { getVectorContext } from 'ol/render'; import { getVectorContext } from 'ol/render';
import { unByKey } from 'ol/Observable'; import { unByKey } from 'ol/Observable';
import { register } from 'ol/proj/proj4';
import proj4 from 'proj4';
import { Stop, StopShape } from './types'; 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 "./stopsSearchMenu.scss"; 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>; 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;
@@ -65,11 +53,14 @@ interface SearchStore {
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[];
displayedPanelId: number; displayedPanelId: number;
panels: PositionedPanel[]; panels: PositionedPanel[];
@@ -79,7 +70,7 @@ function SearchProvider(props: { children: JSX.Element }) {
const [store, setStore] = createStore<Store>({ const [store, setStore] = createStore<Store>({
searchText: "", searchText: "",
searchInProgress: false, searchPromise: undefined,
foundStops: [], foundStops: [],
displayedPanelId: 0, displayedPanelId: 0,
panels: [], panels: [],
@@ -91,22 +82,30 @@ function SearchProvider(props: { children: JSX.Element }) {
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) => {
setStore('searchText', text); clearTimeout(timerId);
timerId = setTimeout(fn, delayMs, ...args);
const { searchStopByName } = businessDataStore; });
console.log("store.searchText=", store.searchText);
const stopsById = await searchStopByName(store.searchText);
console.log("stopsById=", stopsById);
setFoundStops(Object.values(stopsById));
setStore('searchInProgress', false);
} }
const isSearchInProgress = (): boolean => { const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => {
return store.searchInProgress; 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[] => { const getFoundStops = (): Stop[] => {
@@ -158,10 +157,9 @@ function SearchProvider(props: { children: JSX.Element }) {
setStore('mapFeatures', stopId, feature); 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,
getPanels, setPanels, getPanels, setPanels,
@@ -173,6 +171,13 @@ function SearchProvider(props: { children: JSX.Element }) {
); );
} }
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 Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => {
@@ -182,13 +187,11 @@ 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 */
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);
} }
} }
@@ -202,12 +205,7 @@ const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) =>
</text> </text>
</svg> </svg>
</div> </div>
<div class="inputGroup"> <StopNameInput onInput={onStopNameInput} leftAddon="🚉 🚏" placeholder="Stop name..." />
<InputGroup >
<InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={onStopNameInput} readOnly={isSearchInProgress()} placeholder="Stop name..." />
</InputGroup>
</div>
</div > </div >
); );
}; };
@@ -352,7 +350,6 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
} }
const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore; const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore;
let stopsPanelsRef: HTMLDivElement | undefined = undefined let stopsPanelsRef: HTMLDivElement | undefined = undefined
const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef); const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef);
const yStopsPanelsScroll = () => stopsPanelsScroll.y; const yStopsPanelsScroll = () => stopsPanelsScroll.y;
@@ -498,15 +495,13 @@ const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (prop
let feature = undefined; let feature = undefined;
if (props.stop.lat !== undefined && props.stop.lon !== undefined) { if (props.stop.epsg3857_x !== undefined && props.stop.epsg3857_y !== undefined) {
const selectStopStyle = () => { const selectStopStyle = () => {
return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle); return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle);
} }
feature = new OlFeature({ feature = new OlFeature({
geometry: new OlPoint(fromLonLat([props.stop.lon, props.stop.lat])), geometry: new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]),
}); });
feature.setStyle(selectStopStyle); feature.setStyle(selectStopStyle);
} }
@@ -514,11 +509,10 @@ const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (prop
let geometry = undefined; let geometry = undefined;
const areaShape = shape(); const areaShape = shape();
if (areaShape !== undefined) { if (areaShape !== undefined) {
const transformed = areaShape.points.map(point => fromLonLat(toLonLat(point, 'EPSG:2154'))); geometry = new OlPolygon([areaShape.epsg3857_points.slice(0, -1)]);
geometry = new OlPolygon([transformed.slice(0, -1)]);
} }
else { else {
geometry = new OlPoint(fromLonLat([props.stop.lon, props.stop.lat])); geometry = new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]);
} }
feature = new OlFeature({ geometry: geometry }); feature = new OlFeature({ geometry: geometry });
feature.setStyle(stopAreaStyle); feature.setStyle(stopAreaStyle);
@@ -604,11 +598,11 @@ const Map: ParentComponent<{}> = () => {
const stopId: number = feature.getId(); const stopId: number = feature.getId();
const stop = getStop(stopId); const stop = getStop(stopId);
// TODO: Handle StopArea (use center given by the backend) // TODO: Handle StopArea (use center given by the backend)
if (stop?.lat !== undefined && stop?.lon !== undefined) { if (stop?.epsg3857_x !== undefined && stop?.epsg3857_y !== undefined) {
setSelectedMapStop(stop); setSelectedMapStop(stop);
map?.getView().animate( map?.getView().animate(
{ {
center: fromLonLat([stop.lon, stop.lat]), center: [stop.epsg3857_x, stop.epsg3857_y],
duration: 1000 duration: 1000
}, },
// Display the popup once the animation finished // Display the popup once the animation finished

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;
} }
}; };