39 Commits

Author SHA1 Message Date
642433994d ♻️ Replace onMount hook by the use of a signal to set the Map target 2024-02-25 12:40:53 +01:00
922760de3e ⬆️ Bump OpenLayers version (7.3.0 -> 8.2.0) 2024-02-25 12:28:35 +01:00
798447760f 🐛 Let the back-end fails when it fails to connect to the database 2024-02-15 22:28:19 +01:00
d9e35a4c8b ⬆️ Bump python-lsp-ruff from 1.0.5 to 2.1.0 2024-02-15 22:08:43 +01:00
e98b24303c 🎨 Cleanup code formatting 2024-02-02 23:02:39 +01:00
f6bd241db3 Merge branch 'readme' into develop 2024-01-08 23:05:38 +01:00
eead35f822 📝 Add Docs section to the TODO list 2024-01-08 23:03:59 +01:00
3ff9a91bd0 Merge branch 'readme' into develop 2024-01-07 11:44:23 +01:00
d7bef20ffc 📄 Rewrite reference of the origin of the project name 2024-01-07 11:42:44 +01:00
c2c8c81759 📄 Replace WIP tag with a reference to the origin of the project name 2024-01-07 11:39:04 +01:00
98dbf5dfe2 📄 Add WIP tag to the README file 2024-01-07 11:35:46 +01:00
3873fdb719 Merge branch 'readme' into develop 2024-01-07 11:30:03 +01:00
4236f33cb9 📝 Add technical stack description to the README 2024-01-07 11:19:14 +01:00
60ce90b633 🚧 Add C4 Container diagram 2024-01-06 17:45:58 +01:00
c52fc69560 🚧 Add plantuml block to the README to test gitea rendering 2024-01-06 13:32:17 +01:00
e9a13c662e 📝 Change the presentation video chroma subsampling
Cf. https://bugzilla.mozilla.org/show_bug.cgi?id=1368063
2023-12-22 11:36:01 +01:00
93625f12b1 📝 Add thumbnail to the presentation video 2023-12-22 11:27:30 +01:00
b7ed3f83b8 📝 Add main readme file
Signed-off-by: Adrien <me@adrien.run>
2023-12-22 11:03:29 +01:00
f1a47c9621 📝 First try to embed a video in the readme page 2023-12-22 10:59:44 +01:00
7843309f0a 📝 Add a video showing what carrramba-encore-rate service is 2023-12-22 10:48:22 +01:00
cebc9077c3 👽️ Add of the StopAreaStopAssociationFields pdeid and pdeversion fields
These fields have been added by IDFM in its relations resource.
For now, these fields are not used.
2023-10-22 23:41:27 +02:00
f862e124a6 Merge branch 'k8s-integration' into develop 2023-10-22 23:37:44 +02:00
1bb75b28eb ♻️ Use of relative imports for api modules 2023-10-22 23:34:58 +02:00
0a7337a313 ♻️ Put api_server and db_updater scripts on the backend root 2023-10-22 23:31:35 +02:00
fcd1ae78c3 🐛 Fix IP to bind in the api config file for local use 2023-09-20 22:24:02 +02:00
ec1b4100a3 🐛 Fix validation issue in /api/stop/{stop_id}/destinations responses 2023-09-20 22:22:13 +02:00
37ec05bf3b Merge branch 'k8s-integration' into develop 2023-09-20 22:14:56 +02:00
3434802b31 🎨 Reorganize back-end code 2023-09-20 22:08:32 +02:00
bdbc72ab39 🐛 Front: Fix URL used to fetch transport mode representation 2023-09-10 12:25:38 +02:00
4cc8f60076 🐛 Front: Use the public API server to fetch data 2023-09-10 12:17:48 +02:00
cf5c4c6224 🔒️ Fix CORS allowed origins and methods 2023-09-10 12:07:20 +02:00
f69aee1c9c 🔒️ Remove driver and password from configuration file
Password will be provided by vault using an env variable.
2023-09-10 12:04:25 +02:00
8c493f8fab ♻️ Remove pg_trgm creation from the db session init
The pg_trgm extension will be created during db init, by the db-updated image.
2023-09-10 11:46:24 +02:00
4fce832db5 ♻️ Rename docker file building api image 2023-09-10 11:45:08 +02:00
bfc669cd11 ♻️ Use pydantic-settings to handle config file 2023-09-09 23:35:18 +02:00
4056b3a739 🐛 Error raised by frontend Map component if no stop found 2023-09-09 23:18:03 +02:00
f7f0fdb980 ️ Use of integer to store Line and Stop id
Update Line and Stop schemas.
2023-09-09 23:05:18 +02:00
6c149e844b 💥 Remove /widget static endpoint
This endpoint shall be served by a dedicated static HTTP server.
2023-06-13 05:45:33 +02:00
f5529bba24 Merge branch 'remove-db-filling-from-backend' into develop 2023-06-13 05:44:00 +02:00
61 changed files with 2205 additions and 2127 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
medias/presentation.png filter=lfs diff=lfs merge=lfs -text
medias/presentation.mp4 filter=lfs diff=lfs merge=lfs -text

124
README.md Normal file
View File

@@ -0,0 +1,124 @@
# 🚧 Carrramba! Encore raté! 🚧
Resident of the Ile-de-France,
- Tired of missing your bus/train/metro ?
- Tired of having to walk to know where your bus/train/metro will stop ?
- Are you looking to display its next passages in the same way as Ile De France Mobilité ?
**Visit [carrramba.adrien.run](https://carrramba.adrien.run/)**
[![Presentation](medias/presentation.png)](medias/presentation.mp4)
# Technical stack
The following figure list the building blocks (docker images) and their interactions to provide the service:
```plantuml
@startuml
!include <C4/C4_Container>
!define ICONURL https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/v2.4.0
!includeurl ICONURL/common.puml
!includeurl ICONURL/font-awesome-5/users.puml
!includeurl ICONURL/devicons2/redis.puml
!includeurl ICONURL/devicons2/sqlalchemy.puml
!includeurl ICONURL/devicons2/postgresql.puml
title Carrramba-encore-rate - Back-end
LAYOUT_WITH_LEGEND()
Person(user, "User", "The service user", $sprite="users")
System_Boundary(cb1, "carrramba-encore-rate") {
Container(app, "SPA", "SolidJS", "The graphical interface used by users to consume services provided")
Container(api, "carrramba-encore-rate-api", "FastAPI / Pydantic 2 / SQLAlchemy 2", "Provides the functionalities serving API endpoints", $sprite="sqlalchemy")
ContainerDb(db, "Postgres", "PostgreSQL database", "Stores stops/stop areas/lines/shapes and associations information.", $sprite="postgresql")
ContainerDb(cache, "In-Memory cache", "Redis", "Store previously computed results (stop, line, destination, next passages, shapes).", $sprite="redis")
Container(db_updater, "db-updater", "Sync the service static data with the IDFM ones")
}
System_Boundary(idfm, "IDFM") {
Container_Ext(static_, "Static data")
Container_Ext(dynamic_, "Dynamic data")
}
Rel(user, app, "Uses")
Rel(app, api, "Uses", "JSON/HTTPS")
Rel_R(api, db, "Reads from", "sync, PSQL")
Rel_L(api, cache, "Reads from and writes to", "sync, REdis Serialization Protocol")
Rel(api, dynamic_, "Get next passages", "JSON/HTTPS")
Rel_L(db_updater, db, "Writes to", "sync, PSQL")
Rel(db_updater, static_, "Get stops, lines andshapes")
@enduml
```
## Back-end
Conventional but efficient:
- [FastAPI](https://fastapi.tiangolo.com/): _FastAPI is a modern, fast (high-performance), web framework for building
APIs with Python 3.8+ based on standard Python type hints._
- [Pydantic 2](https://docs.pydantic.dev/latest/): _Pydantic is the most widely used data validation library for
Python._
- [Sqlalchemy 2](https://www.sqlalchemy.org/): _SQLAlchemy is the Python SQL toolkit and Object Relational Mapper that
gives application developers the full power and flexibility of SQL._
The [Msgspec](https://github.com/jcrist/msgspec) library is used to serialize JSON objects collected from IDFM API
endpoints, this library being faster than Pydantic.
## Front-end
The component is developed using the [SolidJS](https://www.solidjs.com/) library. It brings the following interesting specificities:
- Share with React the same programmatic structures and support for component.
- Fine-grained reactivity architecture: no virtual DOM used to update the components, the browser DOM is directly
updated by SolidJS.
The front-end tries to be as close as possible to the design defined by the IDFM for the displays deployed by the
transport operators in Ile-de-France. These specifications are public and available here:
[PRIM-IDFM](https://prim.iledefrance-mobilites.fr/en/chartes-et-prescriptions).
# TODO
## Features
- [ ] Integration with [Matrix.org](https://matrix.org/ecosystem/integrations/) ecosystem: make the app able to send message to a room when a bus/train/metro will
arriving in X minutes.
- [ ] Add the capability for the users to pin his/her favorite stops.
- [ ] Add the address to the stop location.
## Docs
- [ ] Describe how to build the front-end and back-end docker images.
- [ ] Describe how to deploy them using `docker compose`.
- [ ] Add back-end API description ([openAPI](https://www.openapis.org/)) + generate documentation
([mkdoc](https://www.mkdocs.org/), [Redoc](https://github.com/Redocly/redoc)). The best would be to build and
deploy it (as docker images) using CI/CD... need to test [Agola](https://agola.io/), [Jaypore
CI](https://www.jayporeci.in/) (pipelines configured in Python),
[Woodpecker](https://woodpecker-ci.org/docs/intro) or [Drone](https://www.drone.io/) before.
## Front-end
- [ ] Add unit tests.
- [ ] Make the StopNameInput component liquid.
- [ ] Liquid to responsive Design.
## Back-end
- [ ] Add unit tests.
- [ ] Use [alembic](https://alembic.sqlalchemy.org/en/latest/) to manage the future updates of the database schemas.
- [ ] Add the capability to reload the application configuration on configuration file update (e.g.: credential update by the vault).
- [ ] Rework how the database is updated with the IDFM data: For now the database is cleaned before refilling. It could
be useful to avoid to empty the database and only apply deltas (update/add/remove rows).
- [ ] Could be nice to compare FastAPI with [Litestar](https://litestar.dev/).
<!-- LocalWords: specificities
-->

View File

@@ -9,7 +9,7 @@ ENV POETRY_NO_INTERACTION=1 \
WORKDIR /app
COPY ./pyproject.toml /app
COPY pyproject.toml /app
RUN poetry install --only=main --no-root && \
rm -rf ${POETRY_CACHE_DIR}
@@ -29,10 +29,8 @@ env VIRTUAL_ENV=/app/.venv \
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY backend /app/backend
COPY dependencies.py /app
COPY config.sample.yaml /app
COPY routers/ /app/routers
COPY main.py /app
COPY api /app/api
COPY config.sample.yaml .
COPY api_server.py .
CMD ["python", "./main.py"]
CMD ["./api_server.py"]

View File

@@ -34,10 +34,8 @@ env VIRTUAL_ENV=/app/.venv \
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY backend /app/backend
COPY dependencies.py /app
COPY config.sample.yaml /app
COPY config.local.yaml /app
COPY db_updater /app/db_updater
COPY api /app/api
COPY config.sample.yaml .
COPY db_updater.py .
CMD ["python", "-m", "db_updater.fill_db"]
CMD ["./db_updater.py"]

View File

@@ -0,0 +1,9 @@
# Architecture
The following schema shows the components used to satisfy the `api` service:
![API](./docs/medias/hubble-ui_api.png)

View File

@@ -1,10 +1,7 @@
from asyncio import sleep
from logging import getLogger
from typing import Annotated, AsyncIterator
from fastapi import Depends
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from sqlalchemy import text
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.ext.asyncio import (
async_sessionmaker,
@@ -16,6 +13,7 @@ from sqlalchemy.ext.asyncio import (
from .base_class import Base
from ..settings import DatabaseSettings
logger = getLogger(__name__)
@@ -30,8 +28,7 @@ class Database:
except (SQLAlchemyError, AttributeError) as e:
logger.exception(e)
return None
raise
# TODO: Preserve UserLastStopSearchResults table from drop.
async def connect(
@@ -61,9 +58,6 @@ class Database:
while not ret:
try:
async with self._async_engine.begin() as session:
await session.execute(
text("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
)
if clear_static_data:
await session.run_sync(Base.metadata.drop_all)
await session.run_sync(Base.metadata.create_all)

View File

@@ -4,9 +4,9 @@ from fastapi_cache.backends.redis import RedisBackend
from redis import asyncio as aioredis
from yaml import safe_load
from backend.db import db
from backend.idfm_interface.idfm_interface import IdfmInterface
from backend.settings import CacheSettings, Settings
from .db import db
from .idfm_interface.idfm_interface import IdfmInterface
from .settings import CacheSettings, Settings
CONFIG_PATH = environ.get("CONFIG_PATH", "./config.sample.yaml")

View File

@@ -7,9 +7,9 @@ from aiohttp import ClientSession
from msgspec import ValidationError
from msgspec.json import Decoder
from .idfm_types import Destinations as IdfmDestinations, IdfmResponse, IdfmState
from ..db import Database
from ..models import Line, Stop, StopArea
from .idfm_types import Destinations as IdfmDestinations, IdfmResponse, IdfmState
class IdfmInterface:
@@ -31,6 +31,10 @@ class IdfmInterface:
async def startup(self) -> None:
...
@staticmethod
def _format_line_id(line_id: str) -> int:
return int(line_id[1:] if line_id[0] == "C" else line_id)
async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]:
line_picto_path = line_picto_format = None
target = f"/tmp/{line.id}_repr"
@@ -81,7 +85,6 @@ class IdfmInterface:
if (stop := await Stop.get_by_id(stop_id)) is not None:
expected_stop_ids = {stop.id}
elif (stop_area := await StopArea.get_by_id(stop_id)) is not None:
expected_stop_ids = {stop.id for stop in stop_area.stops}
else:
@@ -105,7 +108,8 @@ class IdfmInterface:
if (
dst_names := journey.DestinationName
) and monitored_stop_id in expected_stop_ids:
line_id = journey.LineRef.value.split(":")[-2]
raw_line_id = journey.LineRef.value.split(":")[-2]
line_id = IdfmInterface._format_line_id(raw_line_id)
destinations[line_id].add(dst_names[0].value)
return destinations

View File

@@ -141,6 +141,8 @@ class StopAreaStopAssociationFields(Struct, kw_only=True):
artid: str | None = None
arrversion: str
zdcid: str
pdeid: str | None = None
pdeversion: int | None = None
version: int
zdaid: str
zdaversion: str
@@ -205,7 +207,7 @@ class Line(Struct):
Lines = dict[str, Line]
Destinations = dict[str, set[str]]
Destinations = dict[int, set[str]]
# TODO: Set structs frozen

View File

@@ -1,8 +1,8 @@
from fastapi import APIRouter, HTTPException
from fastapi_cache.decorator import cache
from backend.models import Line
from backend.schemas import Line as LineSchema, TransportMode
from ..models import Line
from ..schemas import Line as LineSchema, TransportMode
router = APIRouter(prefix="/line", tags=["line"])

View File

@@ -5,16 +5,16 @@ from typing import Sequence
from fastapi import APIRouter, HTTPException
from fastapi_cache.decorator import cache
from backend.idfm_interface import Destinations as IdfmDestinations, TrainStatus
from backend.models import Stop, StopArea, StopShape
from backend.schemas import (
from ..idfm_interface import Destinations as IdfmDestinations, TrainStatus
from ..models import Stop, StopArea, StopShape
from ..schemas import (
NextPassage as NextPassageSchema,
NextPassages as NextPassagesSchema,
Stop as StopSchema,
StopArea as StopAreaSchema,
StopShape as StopShapeSchema,
)
from dependencies import idfm_interface
from ..dependencies import idfm_interface
router = APIRouter(prefix="/stop", tags=["stop"])

View File

@@ -53,8 +53,8 @@ class Line(BaseModel):
transportMode: TransportMode
backColorHexa: str
foreColorHexa: str
operatorId: str
operatorId: int
accessibility: IdfmState
visualSignsAvailable: IdfmState
audibleSignsAvailable: IdfmState
stopIds: list[str]
stopIds: list[int]

View File

@@ -9,7 +9,7 @@ class Stop(BaseModel):
town: str
epsg3857_x: float
epsg3857_y: float
lines: list[str]
lines: list[int]
class StopArea(BaseModel):
@@ -17,7 +17,7 @@ class StopArea(BaseModel):
name: str
town: str
type: StopAreaType
lines: list[str] # SNCF lines are linked to stop areas and not stops.
lines: list[int] # SNCF lines are linked to stop areas and not stops.
stops: list[Stop]

74
backend/api/settings.py Normal file
View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Annotated
from pydantic import BaseModel, SecretStr
from pydantic.functional_validators import model_validator
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
class HttpSettings(BaseModel):
host: str = "127.0.0.1"
port: int = 8080
cert: str | None = None
class DatabaseSettings(BaseModel):
name: str
host: str
port: int
driver: str = "postgresql+psycopg"
user: str
password: Annotated[SecretStr, check_user_password]
class CacheSettings(BaseModel):
enable: bool = False
host: str = "127.0.0.1"
port: int = 6379
user: str | None = None
password: Annotated[SecretStr | None, check_user_password] = None
@model_validator(mode="after")
def check_user_password(self) -> DatabaseSettings | CacheSettings:
if self.user is not None and self.password is None:
raise ValueError("user is set, password shall be set too.")
if self.password is not None and self.user is None:
raise ValueError("password is set, user shall be set too.")
return self
class TracingSettings(BaseModel):
enable: bool = False
class Settings(BaseSettings):
app_name: str
idfm_api_key: SecretStr
clear_static_data: bool
http: HttpSettings
db: DatabaseSettings
cache: CacheSettings
tracing: TracingSettings
model_config = SettingsConfigDict(env_prefix="CER__", env_nested_delimiter="__")
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return env_settings, init_settings, file_secret_settings

View File

@@ -4,7 +4,6 @@ import uvicorn
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi_cache import FastAPICache
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
@@ -13,9 +12,9 @@ from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from backend.db import db
from dependencies import idfm_interface, redis_backend, settings
from routers import line, stop
from api.db import db
from api.dependencies import idfm_interface, redis_backend, settings
from api.routers import line, stop
@asynccontextmanager
@@ -35,14 +34,12 @@ app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://localhost:4443", "https://localhost:3000"],
allow_origins=["http://carrramba.adrien.run", "https://carrramba.adrien.run"],
allow_credentials=True,
allow_methods=["*"],
allow_methods=["OPTIONS", "GET"],
allow_headers=["*"],
)
app.mount("/widget", StaticFiles(directory="../frontend/", html=True), name="widget")
# The cache-control header entry is not managed properly by fastapi-cache:
# For now, a request with a cache-control set to no-cache
# is interpreted as disabling the use of the server cache.
@@ -60,7 +57,6 @@ async def fastapi_cache_issue_144_workaround(request: Request, call_next):
return await call_next(request)
app.include_router(line.router)
app.include_router(stop.router)

View File

@@ -1,59 +0,0 @@
from typing import Any
from pydantic import BaseModel, BaseSettings, Field, root_validator, SecretStr
class HttpSettings(BaseModel):
host: str = "127.0.0.1"
port: int = 8080
cert: str | None = None
def check_user_password(cls, values: dict[str, Any]) -> dict[str, Any]:
user = values.get("user")
password = values.get("password")
if user is not None and password is None:
raise ValueError("user is set, password shall be set too.")
if password is not None and user is None:
raise ValueError("password is set, user shall be set too.")
return values
class DatabaseSettings(BaseModel):
name: str = "carrramba-encore-rate"
host: str = "127.0.0.1"
port: int = 5432
driver: str = "postgresql+psycopg"
user: str | None = None
password: SecretStr | None = None
_user_password_validation = root_validator(allow_reuse=True)(check_user_password)
class CacheSettings(BaseModel):
enable: bool = False
host: str = "127.0.0.1"
port: int = 6379
user: str | None = None
password: SecretStr | None = None
_user_password_validation = root_validator(allow_reuse=True)(check_user_password)
class TracingSettings(BaseModel):
enable: bool = False
class Settings(BaseSettings):
app_name: str
idfm_api_key: SecretStr = Field(..., env="IDFM_API_KEY")
clear_static_data: bool = Field(False, env="CLEAR_STATIC_DATA")
http: HttpSettings = HttpSettings()
db: DatabaseSettings = DatabaseSettings()
cache: CacheSettings = CacheSettings()
tracing: TracingSettings = TracingSettings()

View File

@@ -2,7 +2,7 @@ app_name: carrramba-encore-rate
clear_static_data: false
http:
host: 0.0.0.0
host: 127.0.0.1
port: 8080
cert: ./config/cert.pem

View File

@@ -10,9 +10,7 @@ db:
name: carrramba-encore-rate
host: postgres
port: 5432
driver: postgresql+psycopg
user: cer
password: cer_password
cache:
enable: true

View File

@@ -16,9 +16,9 @@ from shapefile import Reader as ShapeFileReader, ShapeRecord # type: ignore
from tqdm import tqdm
from yaml import safe_load
from backend.db import Base, db, Database
from backend.models import ConnectionArea, Line, LinePicto, Stop, StopArea, StopShape
from backend.idfm_interface.idfm_types import (
from api.db import Base, db, Database
from api.models import ConnectionArea, Line, LinePicto, Stop, StopArea, StopShape
from api.idfm_interface.idfm_types import (
ConnectionArea as IdfmConnectionArea,
IdfmLineState,
Line as IdfmLine,
@@ -31,8 +31,8 @@ from backend.idfm_interface.idfm_types import (
StopLineAsso as IdfmStopLineAsso,
TransportMode,
)
from backend.idfm_interface.ratp_types import Picto as RatpPicto
from backend.settings import Settings
from api.idfm_interface.ratp_types import Picto as RatpPicto
from api.settings import Settings
CONFIG_PATH = environ.get("CONFIG_PATH", "./config.sample.yaml")

View File

@@ -9,7 +9,7 @@ readme = "README.md"
python = "^3.11"
aiohttp = "^3.8.3"
aiofiles = "^22.1.0"
fastapi = "^0.95.0"
fastapi = "^0.103.0"
uvicorn = "^0.20.0"
msgspec = "^0.12.0"
opentelemetry-instrumentation-fastapi = "^0.38b0"
@@ -23,11 +23,12 @@ sqlalchemy = "^2.0.12"
psycopg = "^3.1.9"
pyyaml = "^6.0"
fastapi-cache2 = {extras = ["redis"], version = "^0.2.1"}
pydantic-settings = "^2.0.3"
[tool.poetry.group.db_updater.dependencies]
aiofiles = "^22.1.0"
aiohttp = "^3.8.3"
fastapi = "^0.95.0"
fastapi = "^0.103.0"
msgspec = "^0.12.0"
opentelemetry-instrumentation-fastapi = "^0.38b0"
opentelemetry-instrumentation-sqlalchemy = "^0.38b0"
@@ -41,11 +42,14 @@ pyyaml = "^6.0"
sqlalchemy = "^2.0.12"
sqlalchemy-utils = "^0.41.1"
tqdm = "^4.65.0"
pydantic-settings = "^2.0.3"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.group.dev.dependencies]
pylsp-mypy = "^0.6.2"
mccabe = "^0.7.0"
@@ -56,7 +60,7 @@ types-aiofiles = "^22.1.0.2"
wrapt = "^1.14.1"
pydocstyle = "^6.2.2"
dill = "^0.3.6"
python-lsp-ruff = "^1.0.5"
python-lsp-ruff = "^2.1.0"
python-lsp-server = "^1.7.1"
autopep8 = "^2.0.1"
pyflakes = "^3.0.1"

View File

@@ -30,8 +30,9 @@
"@stitches/core": "^1.2.8",
"date-fns": "^2.29.3",
"matrix-widget-api": "^1.1.1",
"ol": "^7.3.0",
"ol": "^8.2.0",
"solid-js": "^1.6.6",
"solid-transition-group": "^0.0.10"
"solid-transition-group": "^0.0.10",
"solidjs-lazily": "^0.1.2"
}
}

View File

@@ -1,15 +1,12 @@
import { Component, createSignal } from 'solid-js';
import { IVisibilityActionRequest, MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction } from 'matrix-widget-api';
import { HopeProvider } from "@hope-ui/solid";
import { Component, createSignal, onCleanup, onMount } from 'solid-js';
// import { IVisibilityActionRequest, MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction } from 'matrix-widget-api';
import { BusinessDataProvider } from './businessData';
import { AppContextProvider } from './appContext';
import { PassagesDisplay } from './passagesDisplay';
import { StopsSearchMenu } from './stopsSearchMenu/stopsSearchMenu';
import "./App.scss";
import { onCleanup, onMount } from 'solid-js';
function parseFragment() {
@@ -28,21 +25,21 @@ const App: Component = () => {
console.log("App: widgetId:" + widgetId);
console.log("App: userId:" + userId);
const api = new WidgetApi(widgetId != null ? widgetId : undefined);
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
api.start();
api.on("ready", function() {
console.log("App: widget API is READY !!!!");
});
// const api = new WidgetApi(widgetId != null ? widgetId : undefined);
// api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
// api.start();
// api.on("ready", function() {
// console.log("App: widget API is READY !!!!");
// });
// Seems to don´t be used...
api.on(`action:${WidgetApiToWidgetAction.UpdateVisibility}`, (ev: CustomEvent<IVisibilityActionRequest>) => {
console.log("App: Visibility change");
ev.preventDefault(); // we're handling it, so stop the widget API from doing something.
console.log("App: ", ev.detail); // custom handling here
/* api.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); */
api.transport.reply(ev.detail, {});
});
// api.on(`action:${WidgetApiToWidgetAction.UpdateVisibility}`, (ev: CustomEvent<IVisibilityActionRequest>) => {
// console.log("App: Visibility change");
// ev.preventDefault(); // we're handling it, so stop the widget API from doing something.
// console.log("App: ", ev.detail); // custom handling here
// /* api.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); */
// api.transport.reply(ev.detail, {});
// });
createSignal({
height: window.innerHeight,
@@ -71,10 +68,8 @@ const App: Component = () => {
window.removeEventListener('resize', onResize);
})
return (
<BusinessDataProvider>
return <BusinessDataProvider>
<AppContextProvider>
<HopeProvider>
<div class="App">
<div class="panel">
<StopsSearchMenu />
@@ -83,10 +78,8 @@ const App: Component = () => {
<PassagesDisplay />
</div>
</div>
</HopeProvider>
</AppContextProvider>
</BusinessDataProvider>
);
</BusinessDataProvider>;
};
export default App;

View File

@@ -6,7 +6,7 @@ import { Stop } from './types';
export interface AppContextStore {
getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void;
};
}
export const AppContextContext = createContext<AppContextStore>();
@@ -26,10 +26,7 @@ export function AppContextProvider(props: { children: JSX.Element }) {
const setDisplayedStops = (stops: Stop[]): void => {
console.log("setDisplayedStops=", stops);
// setStore((s: Store) => {
setStore('displayedStops', stops);
// return s;
// });
}
return (
@@ -39,5 +36,4 @@ export function AppContextProvider(props: { children: JSX.Element }) {
{props.children}
</AppContextContext.Provider>
);
};
}

View File

@@ -6,6 +6,7 @@ import { Line, Lines, Passage, Passages, Stop, StopShape, StopShapes, Stops } fr
export type StopDestinations = Record<string, string[]>;
export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>;
@@ -23,13 +24,12 @@ export interface BusinessDataStore {
getStopDestinations: (stopId: number) => Promise<StopDestinations | undefined>;
getStopShape: (stopId: number) => Promise<StopShape | undefined>;
};
}
export const BusinessDataContext = createContext<BusinessDataStore>();
export function BusinessDataProvider(props: { children: JSX.Element }) {
const [serverUrl] = createSignal<string>("https://localhost:4443");
const [serverUrl] = createSignal<string>("https://carrramba.adrien.run/api");
type Store = {
lines: Lines;
@@ -121,7 +121,7 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
for (const lineId of Object.keys(passages)) {
const newLinePassages = passages[lineId];
const linePassages = storePassages[lineId];
if (linePassages === undefined) {
if (linePassages === undefined || Object.keys(linePassages).length == 0) {
setStore('passages', lineId, newLinePassages);
}
else {
@@ -160,7 +160,7 @@ ${linePassagesDestination.length} here... refresh all them.`);
const clearPassages = (): void => {
setStore((s: Store): Store => {
for (const lineId of Object.keys(s.passages)) {
setStore('passages', lineId, undefined);
setStore('passages', lineId, {});
}
return s;
});
@@ -232,21 +232,9 @@ ${linePassagesDestination.length} here... refresh all them.`);
getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds,
refreshPassages, addPassages, clearPassages,
getStop, getStopDestinations, getStopShape, searchStopByName
}}>
{props.children}
</BusinessDataContext.Provider>
);
}
export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>;
passages: () => Passages;
refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void;
clearPassages: () => void;
getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>;
};

View File

@@ -4,10 +4,11 @@ import { VoidComponent } from "solid-js";
export const IconHamburgerMenu: VoidComponent<{}> = () => {
return (
<svg class="iconHamburgerMenu" 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
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"
<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
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-rule="evenodd"
clip-rule="evenodd"

View File

@@ -14,6 +14,7 @@
}
html, body {
height: 100vh;
aspect-ratio: 16/9;
margin: 0;

View File

@@ -1,7 +1,6 @@
import { createContext, createEffect, createResource, createSignal, For, JSX, ParentComponent, Show, useContext, VoidComponent } from "solid-js";
import { createStore } from "solid-js/store";
import { createDateNow } from "@solid-primitives/date";
import { IconButton, Menu, MenuTrigger, MenuContent, MenuItem } from "@hope-ui/solid";
import { format } from "date-fns";
import { BusinessDataContext, BusinessDataStore } from "./businessData";
@@ -9,7 +8,6 @@ import { AppContextContext, AppContextStore } from "./appContext";
import { getTransportModeSrc, PositionedPanel } from "./utils";
import { PassagesPanel } from "./passagesPanel";
import { IconHamburgerMenu } from './extra/iconHamburgerMenu';
import "./passagesDisplay.scss";
@@ -25,7 +23,7 @@ interface PassagesDisplayStore {
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
};
}
const PassagesDisplayContext = createContext<PassagesDisplayStore>();
@@ -91,7 +89,6 @@ const Header: VoidComponent<{ title: string }> = (props) => {
return <div />;
const { getLine, passages } = businessDataStore;
const { isPassagesRefreshEnabled, togglePassagesRefresh } = passagesDisplayStore;
const [dateNow] = createDateNow(1000);
@@ -113,16 +110,14 @@ const Header: VoidComponent<{ title: string }> = (props) => {
setLinesIds(Object.keys(passages()));
});
return (
<div class="header">
return <div class="header">
<Show when={transportModeUrls() !== undefined} >
<For each={transportModeUrls()}>
{(url) =>
<div class="transportMode">
<img src={url} />
</div>
}
</For>
}</For>
</Show>
<div class="title">
<svg viewBox="0 0 1260 50">
@@ -132,15 +127,6 @@ const Header: VoidComponent<{ title: string }> = (props) => {
</svg>
</div>
<div class="menu">
<Menu>
<MenuTrigger
as={IconButton}
icon=<IconHamburgerMenu />
/>
<MenuContent>
<MenuItem onSelect={() => togglePassagesRefresh()}>{isPassagesRefreshEnabled() ? "Disable" : "Enable"}</MenuItem>
</MenuContent>
</Menu>
</div>
<div class="clock">
<svg viewBox="0 0 115 43">
@@ -149,8 +135,7 @@ const Header: VoidComponent<{ title: string }> = (props) => {
</text>
</svg>
</div>
</div >
);
</div>;
};
const Footer: VoidComponent<{}> = () => {
@@ -176,8 +161,7 @@ const Footer: VoidComponent<{}> = () => {
</svg>
</div>
);
}}
</For>
}}</For>
</div>
);
}
@@ -286,13 +270,11 @@ export const PassagesDisplay: ParentComponent = () => {
const syncPeriodMsec = 20 * 1000;
const panelSwitchPeriodMsec = 4 * 1000;
return (
<div class="passagesDisplay">
return <div class="passagesDisplay">
<PassagesDisplayProvider>
<Header title="Prochains passages" />
<Body maxPassagesPerPanel={MAX_PASSAGES_PER_PANEL} syncPeriodMsec={syncPeriodMsec} panelSwitchPeriodMsec={panelSwitchPeriodMsec} />
<Footer />
</PassagesDisplayProvider>
</div>
);
</div>;
};

View File

@@ -14,15 +14,13 @@ import "./passagesPanel.scss";
const UnavailablePassage: VoidComponent<{ style: string }> = (props) => {
const textStyle = { fill: "#000000" };
return (
<div class={props.style}>
return <div class={props.style}>
<svg viewBox="0 0 230 110">
<text x="100%" y="26" font-size="25" text-anchor="end" style={textStyle}>Information</text>
<text x="100%" y="63" font-size="25" text-anchor="end" style={textStyle}>non</text>
<text x="100%" y="100" font-size="25" text-anchor="end" style={textStyle}>disponible</text>
</svg>
</div>
);
</div>;
}
const Platform: VoidComponent<{ name: string }> = (props) => {
@@ -43,14 +41,12 @@ const Platform: VoidComponent<{ name: string }> = (props) => {
}
});
return (
<svg class="platform" viewBox={`0 0 ${viewBoxWidthPx} 40`}>
return <svg class="platform" viewBox={`0 0 ${viewBoxWidthPx} 40`}>
<rect ref={rectRef} x="0" y="0" height="100%" rx="9" ry="9" />
<text ref={textRef} x="100%" y="55%" dominant-baseline="middle" text-anchor="end" font-size="25" style={{ fill: "#ffffff" }}>
QUAI {props.name}
</text>
</svg>
);
</svg>;
}
const TtwPassage: VoidComponent<{
@@ -88,8 +84,7 @@ const TtwPassage: VoidComponent<{
</Motion.text>
</svg>;
return (
<Show when={passage !== undefined} fallback=<UnavailablePassage style={props.fallbackStyle} />>
return <Show when={passage !== undefined} fallback={<UnavailablePassage style={props.fallbackStyle} />}>
<Show
when={passage.arrivalPlatformName !== null}
fallback={
@@ -100,9 +95,8 @@ const TtwPassage: VoidComponent<{
{text}
<Platform name={passage.arrivalPlatformName} />
</div>
</Show >
</Show >
);
</Show>
</Show>;
});
}
@@ -122,8 +116,7 @@ const DestinationPassages: VoidComponent<{ line: Line, destination: string }> =
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
return (
<div class="line">
return <div class="line">
<div class="transportMode">
{renderLineTransportMode(props.line)}
</div>
@@ -142,8 +135,7 @@ const DestinationPassages: VoidComponent<{ line: Line, destination: string }> =
<TtwPassage line={props.line} destination={props.destination} index={1}
style="secondPassage" withPlatformStyle="withPlatformSecondPassage"
fontSize={45} fallbackStyle="unavailableSecondPassage" />
</div >
);
</div>;
}
export type PassagesPanelComponentProps = ParentProps & { stopId: number, lineIds: string[], show: boolean };
@@ -162,19 +154,16 @@ export const PassagesPanel: PassagesPanelComponent = (props) => {
}
const [lines] = createResource<Line[], string[]>(props.lineIds, getLines);
return (
<div classList={{ ["passagesContainer"]: true, ["displayed"]: props.show }} >
return <div classList={{ "passagesContainer": true, "displayed": props.show }} >
<Show when={lines() !== undefined} >
<For each={lines()}>
{(line) =>
<Show when={getLineDestinations(line.id) !== undefined}>
<For each={getLineDestinations(line.id)}>
{(destination) => <DestinationPassages line={line} destination={destination} />}
</For>
<For each={getLineDestinations(line.id)}>{
(destination) => <DestinationPassages line={line} destination={destination} />
}</For>
</Show>
}
</For>
}</For>
</Show>
</div >
);
</div>;
}

View File

@@ -1,8 +1,8 @@
import { batch, createContext, JSX } from 'solid-js';
import { createContext, JSX } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Marker as LeafletMarker } from 'leaflet';
import { Stop, Stops } from './types';
import { Stop } from './types';
export type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>;
@@ -52,9 +52,7 @@ export function SearchProvider(props: { children: JSX.Element }) {
setStore('markers', stopId, markers);
}
return (
<SearchContext.Provider value={{ getFoundStops, setFoundStops, getDisplayedStops, setDisplayedStops, addMarkers }}>
return <SearchContext.Provider value={{ getFoundStops, setFoundStops, getDisplayedStops, setDisplayedStops, addMarkers }}>
{props.children}
</SearchContext.Provider>
);
</SearchContext.Provider>;
}

View File

@@ -41,56 +41,13 @@ export const Map: ParentComponent<{}> = () => {
// 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 [mapRef, setMapRef] = createSignal<HTMLDivElement>();
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);
@@ -108,7 +65,48 @@ export const Map: ParentComponent<{}> = () => {
}
}
onMount(() => buildMap(mapDiv));
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 displayedFeatures: Record<number, OlFeature> = {};
const overlay = new OlOverlay({
element: popup,
autoPan: {
animation: {
duration: 250,
},
},
});
const map = new OlMap({
target: "map",
controls: [], // remove controls
view: new OlView({
center: mapCenter,
zoom: 10,
}),
layers: [
new OlTileLayer({
source: new OlOSM(),
}),
stopVectorLayer,
],
overlays: [overlay],
});
map.on('singleclick', onClickedMap);
createEffect(() => {
map.setTarget(mapRef());
});
// Filling the map with stops shape
createEffect(() => {
@@ -116,8 +114,10 @@ export const Map: ParentComponent<{}> = () => {
const foundStopIds = new Set();
for (const foundStop of stops) {
foundStopIds.add(foundStop.id);
if (foundStop.stops !== undefined) {
foundStop.stops.forEach(s => foundStopIds.add(s.id));
}
}
for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) {
const stopId = parseInt(stopIdStr);
@@ -205,8 +205,8 @@ export const Map: ParentComponent<{}> = () => {
}
return <>
<div ref={mapDiv} class="map">
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} />
<div ref={setMapRef!} class="map">
<StopPopup ref={popup!} stop={selectedMapStop()} show={isPopupDisplayed()} />
</div>
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For>
</>;

View File

@@ -149,8 +149,7 @@ export function SearchProvider(props: { children: JSX.Element }) {
setStore('mapFeatures', stopId, feature);
};
return (
<SearchContext.Provider value={{
return <SearchContext.Provider value={{
getSearchText, setSearchText,
getFoundStops, setFoundStops,
getDisplayedPanelId, setDisplayedPanelId,
@@ -160,6 +159,5 @@ export function SearchProvider(props: { children: JSX.Element }) {
getMapFeature, getAllMapFeatures, setMapFeature,
}}>
{props.children}
</SearchContext.Provider>
);
</SearchContext.Provider>;
}

View File

@@ -33,8 +33,7 @@ const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
const [lineReprs] = createResource<JSX.Element[], string[]>(props.stop.lines, fetchLinesRepr);
return (
<div class="stop">
return <div class="stop">
<svg class="name" viewBox={`0 0 215 ${fontSize}`}>
<text
x="100%" y="55%"
@@ -44,8 +43,7 @@ const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
</text>
</svg>
<For each={lineReprs()}>{(line: JSX.Element) => line}</For>
</div>
);
</div>;
}
@@ -83,7 +81,7 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
if (line !== undefined) {
if (!(line.transportMode in byModeReprs)) {
byModeReprs[line.transportMode] = {
mode: <div class="transportMode">{renderLineTransportMode(line)}</div>,
mode: <div class="transportMode"> {renderLineTransportMode(line)}</div>,
lines: {}
};
}
@@ -91,12 +89,11 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
}
}
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] <
TransportModeWeights[y] ? 1 : -1);
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < TransportModeWeights[y] ? 1 : -1);
return (
<div class="lineRepr">
<For each={sortedTransportModes}>{(transportMode) => {
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 <>
@@ -104,40 +101,34 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
<div class="linesRepresentationMatrix">
<For each={lineNames}>{(lineName) => reprs.lines[lineName]}</For>
</div>
</>
}}
</For>
</div >
);
</>;
}}</For>
</div>;
}
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return (
<div
return <div
class="stop"
onClick={() => setDisplayedStops([props.stop])}
onMouseEnter={() => setHighlightedStop(props.stop)}
onMouseLeave={resetHighlightedStop}
>
<div class="name" >
<div class="name">
<ScrollingText height={fontSize} width={100} content={props.stop.name} />
</div>
{lineReprs()}
</div>
);
</div>;
}
export const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => {
return (
<div classList={{ "stopPanel": true, "displayed": props.show }}>
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>
);
}}</For>
</div>;
}

View File

@@ -29,21 +29,19 @@ export const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props)
}
const [destinations] = createResource(() => props.stop, getDestinations);
return (
<div ref={popupDiv} classList={{ "popup": true, "displayed": props.show }}>
return <div ref={popupDiv} classList={{ "popup": true, "displayed": props.show }}>
<div class="header">{props.stop?.name}</div>
<div class="body">
<div class="body" >
<For each={destinations()}>
{(dst) => {
return <div class='line'>
return <div class='line' >
{dst.lineId}
<div class="name">
< div class="name" >
<ScrollingText height={10} width={130} content={dst.destinations.join('/')} />
</div>
</div>;
}}
</For>
}}</For>
</div>
</div >
);
</div>;
}

View File

@@ -33,6 +33,7 @@
background-color: transparent;
.leftAddon {
width: 17%;

View File

@@ -1,4 +1,4 @@
import { createEffect, For, JSX, lazy, ParentComponent, useContext, Show, VoidComponent } from 'solid-js';
import { createEffect, For, JSX, ParentComponent, useContext, Show, VoidComponent } from 'solid-js';
import { lazily } from 'solidjs-lazily';
import { createScrollPosition } from "@solid-primitives/scroll";
@@ -16,11 +16,10 @@ import "./stopsSearchMenu.scss";
const StopNameInput: VoidComponent<{ onInput: JSX.EventHandler<HTMLInputElement, InputEvent>, leftAddon: string, placeholder: string }> = (props) => {
return (
<div class="stopNameInput">
return <div class="stopNameInput">
<div class="leftAddon">{props.leftAddon}</div>
<input type="text" oninput={props.onInput} placeholder={props.placeholder} />
</div>);
</div>;
};
@@ -40,8 +39,7 @@ const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) =>
}
}
return (
<div class="header">
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">
@@ -50,8 +48,7 @@ const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) =>
</svg>
</div>
<StopNameInput onInput={onStopNameInput} leftAddon="🚉 🚏" placeholder="Stop name..." />
</div >
);
</div>;
};
@@ -71,13 +68,15 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
yStopsPanelsScroll();
for (const panel of getPanels()) {
const panelDiv = panel.panel();
const panelDiv = panel.panel;
if (panelDiv != null) {
const panelDivClientRect = panelDiv.getBoundingClientRect();
if (panelDivClientRect.y > 0) {
setDisplayedPanelId(panel.position);
break;
}
}
}
});
return (
@@ -133,7 +132,8 @@ const MapPlaceholder: VoidComponent<{}> = () => {
}
return <div
class="mapPlaceholder" ondblclick={() => onDoubleClick()}>
class="mapPlaceholder"
ondblclick={() => onDoubleClick()}>
Double-clic pour activer la carte
</div>;
};
@@ -166,35 +166,31 @@ const Footer: VoidComponent<{}> = () => {
const { getDisplayedPanelId, getPanels } = searchStore;
return (
<div class="footer">
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"
<circle
cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3"
style={{ fill: `var(--idfm-${position == getDisplayedPanelId() ? "white" : "black"})` }}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}}</For>
</div>;
};
export const StopsSearchMenu: VoidComponent = () => {
return (
<div class="stopSearchMenu">
return <div class="stopSearchMenu">
<SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} />
<Body />
<Footer />
</SearchProvider>
</div>
);
</div>;
};

View File

@@ -8,7 +8,7 @@ export enum TrafficStatus {
export class Passage {
line: number;
operator: string;
operator: number;
destinations: string[];
atStop: boolean;
aimedArrivalTs: number;
@@ -19,7 +19,7 @@ export class Passage {
arrivalStatus: string;
departStatus: string;
constructor(line: number, operator: string, destinations: string[], atStop: boolean, aimedArrivalTs: number,
constructor(line: number, operator: number, destinations: string[], atStop: boolean, aimedArrivalTs: number,
expectedArrivalTs: number, arrivalPlatformName: string, aimedDepartTs: number, expectedDepartTs: number,
arrivalStatus: string, departStatus: string) {
this.line = line;
@@ -34,7 +34,7 @@ export class Passage {
this.arrivalStatus = arrivalStatus;
this.departStatus = departStatus;
}
};
}
export type Passages = Record<string, Record<string, Passage[]>>;
@@ -45,9 +45,9 @@ export class Stop {
epsg3857_x: number;
epsg3857_y: number;
stops: Stop[];
lines: string[];
lines: number[];
constructor(id: number, name: string, town: string, epsg3857_x: number, epsg3857_y: number, stops: Stop[], lines: string[]) {
constructor(id: number, name: string, town: string, epsg3857_x: number, epsg3857_y: number, stops: Stop[], lines: number[]) {
this.id = id;
this.name = name;
this.town = town;
@@ -59,7 +59,7 @@ export class Stop {
this.lines.push(...stop.lines);
}
}
};
}
export type Stops = Record<number, Stop>;
@@ -77,12 +77,12 @@ export class StopShape {
this.epsg3857_bbox = epsg3857_bbox;
this.epsg3857_points = epsg3857_points;
}
};
}
export type StopShapes = Record<number, StopShape>;
export class Line {
id: string;
id: number;
shortName: string;
name: string;
status: string; // TODO: Use an enum
@@ -95,7 +95,7 @@ export class Line {
audibleSignsAvailable: string; // TODO: Use an enum
stopIds: number[];
constructor(id: string, shortName: string, name: string, status: string, transportMode: string, backColorHexa: string,
constructor(id: number, shortName: string, name: string, status: string, transportMode: string, backColorHexa: string,
foreColorHexa: string, operatorId: number, accessibility: boolean, visualSignsAvailable: string,
audibleSignsAvailable: string, stopIds: number[]) {
this.id = id;
@@ -111,6 +111,6 @@ export class Line {
this.audibleSignsAvailable = audibleSignsAvailable;
this.stopIds = stopIds;
}
};
}
export type Lines = Record<string, Line>;

View File

@@ -26,7 +26,7 @@ export const TransportModeWeights: Record<string, number> = {
export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined {
let ret = undefined;
if (validTransportModes.includes(mode)) {
return `/carrramba-encore-rate/public/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
return `/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
}
return ret;
}
@@ -36,11 +36,11 @@ export function renderLineTransportMode(line: Line): JSX.Element {
}
function renderBusLinePicto(line: Line): JSX.Element {
return (
<div class="busLinePicto">
return <div class="busLinePicto">
<svg viewBox="0 0 31.5 14">
<rect x="0" y="0" width="31.5" height="14" rx="1.5" ry="1.5" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
<text
x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
@@ -49,18 +49,17 @@ function renderBusLinePicto(line: Line): JSX.Element {
{line.shortName}
</text>
</svg>
</div>
);
</div>;
}
function renderTramLinePicto(line: Line): JSX.Element {
const lineStyle = { fill: `#${line.backColorHexa}` };
return (
<div class="tramLinePicto">
return <div class="tramLinePicto">
<svg viewBox="0 0 20 20">
<rect x="0" y="0" width="20" height="3" rx="1" ry="1" style={lineStyle} />
<rect x="0" y="17" width="20" height="3" rx="1" ry="1" style={lineStyle} />
<text x="50%"
<text
x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
@@ -69,16 +68,15 @@ function renderTramLinePicto(line: Line): JSX.Element {
{line.shortName}
</text>
</svg>
</div>
);
</div>;
}
function renderMetroLinePicto(line: Line): JSX.Element {
return (
<div class="metroLinePicto">
return <div class="metroLinePicto">
<svg viewBox="0 0 20 20">
<circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
<text
x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
@@ -86,16 +84,15 @@ function renderMetroLinePicto(line: Line): JSX.Element {
{line.shortName}
</text>
</svg>
</div>
);
</div>;
}
function renderTrainLinePicto(line: Line): JSX.Element {
return (
<div class="trainLinePicto">
return <div class="trainLinePicto">
<svg viewBox="0 0 20 20">
<rect x="0" y="0" width="20" height="20" rx="4.5" ry="4.5" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
<text
x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
@@ -104,8 +101,7 @@ function renderTrainLinePicto(line: Line): JSX.Element {
{line.shortName}
</text>
</svg>
</div>
);
</div>;
}
export function renderLinePicto(line: Line): JSX.Element {
@@ -152,8 +148,7 @@ export const ScrollingText: VoidComponent<{ height: number, width: number, conte
}
});
return (
<svg ref={viewBoxRef} viewBox={`0 0 ${props.width} ${props.height}`}>
return <svg ref={viewBoxRef} viewBox={`0 0 ${props.width} ${props.height}`}>
<text
ref={textRef}
x="0%" y="55%"
@@ -161,6 +156,5 @@ export const ScrollingText: VoidComponent<{ height: number, width: number, conte
font-size={`${props.height}px`}>
{props.content}
</text>
</svg >
);
</svg>;
}

View File

@@ -4,6 +4,7 @@
"jsxImportSource": "solid-js",
"noImplicitAny": true,
"target": "ES6",
"module": "esnext",
"moduleResolution": "node",
"allowJs": true,
"outDir": "build",

BIN
medias/presentation.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
medias/presentation.png (Stored with Git LFS) Normal file

Binary file not shown.