11 Commits

29 changed files with 1952 additions and 2017 deletions

View File

@@ -1,3 +1,4 @@
# 🚧 Carrramba! Encore raté! 🚧
Resident of the Ile-de-France,
@@ -94,6 +95,16 @@ transport operators in Ile-de-France. These specifications are public and availa
- [ ] 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.

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(

View File

@@ -60,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,27 +1,27 @@
.App {
--idfm-black: #2c2e35;
--idfm-white: #ffffff;
--idfm-black: #2c2e35;
--idfm-white: #ffffff;
--neutral-color: #d7dbdf;
--neutral-color: #d7dbdf;
--border-radius: calc(15/1920*100%);
--border-radius: calc(15/1920*100%);
height: inherit;
width: inherit;
height: inherit;
width: inherit;
scroll-snap-type: x mandatory;
overflow-x: scroll;
scroll-snap-type: x mandatory;
overflow-x: scroll;
display: flex;
text-align: center;
display: flex;
text-align: center;
.panel {
min-width: 100%;
height: inherit;
width: inherit;
.panel {
min-width: 100%;
height: inherit;
width: inherit;
scroll-snap-align: center;
scroll-snap-align: center;
background-color: var(--idfm-black);
}
background-color: var(--idfm-black);
}
}

View File

@@ -1,92 +1,85 @@
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() {
const fragmentString = (window.location.hash || "?");
return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0)));
const fragmentString = (window.location.hash || "?");
return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0)));
}
const App: Component = () => {
console.log('App: New');
console.log('App: New');
const qs = parseFragment();
const widgetId = qs.get('widgetId');
const userId = qs.get('userId');
const qs = parseFragment();
const widgetId = qs.get('widgetId');
const userId = qs.get('userId');
console.log("App: widgetId:" + widgetId);
console.log("App: userId:" + userId);
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, {});
});
// 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, {});
// });
createSignal({
height: window.innerHeight,
width: window.innerWidth
});
createSignal({
height: window.innerHeight,
width: window.innerWidth
});
const onResize = () => {
const body = document.body;
const onResize = () => {
const body = document.body;
if (window.innerWidth * 9 / 16 < window.innerHeight) {
body.style['height'] = 'auto';
body.style['width'] = '100vw';
}
else {
body.style['height'] = '100vh';
body.style['width'] = 'auto';
}
};
if (window.innerWidth * 9 / 16 < window.innerHeight) {
body.style['height'] = 'auto';
body.style['width'] = '100vw';
}
else {
body.style['height'] = '100vh';
body.style['width'] = 'auto';
}
};
onMount(() => {
window.addEventListener('resize', onResize);
onResize();
});
onMount(() => {
window.addEventListener('resize', onResize);
onResize();
});
onCleanup(() => {
window.removeEventListener('resize', onResize);
})
onCleanup(() => {
window.removeEventListener('resize', onResize);
})
return (
<BusinessDataProvider>
<AppContextProvider>
<HopeProvider>
<div class="App">
<div class="panel">
<StopsSearchMenu />
</div>
<div class="panel">
<PassagesDisplay />
</div>
</div>
</HopeProvider>
</AppContextProvider>
</BusinessDataProvider>
);
return <BusinessDataProvider>
<AppContextProvider>
<div class="App">
<div class="panel">
<StopsSearchMenu />
</div>
<div class="panel">
<PassagesDisplay />
</div>
</div>
</AppContextProvider>
</BusinessDataProvider>;
};
export default App;

View File

@@ -1,74 +1,74 @@
/* Idfm: 1860x1080px */
%widget {
aspect-ratio: 16/9;
--reverse-aspect-ratio: 9/16;
/* height is set according to the aspect-ratio, don´t touch it */
width: 100%;
aspect-ratio: 16/9;
--reverse-aspect-ratio: 9/16;
/* height is set according to the aspect-ratio, don´t touch it */
width: 100%;
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
/* Idfm: 1800x100px (margin: 17px 60px) */
%header {
width: calc(1800/1920*100%);
height: calc(100/1080*100%);
/*Percentage margin are computed relatively to the nearest block container's width, not height */
/* cf. https://developer.mozilla.org/en-US/docs/Web/CSS/margin-bottom */
margin: calc(17/1080*var(--reverse-aspect-ratio)*100%) calc(60/1920*100%);
width: calc(1800/1920*100%);
height: calc(100/1080*100%);
/*Percentage margin are computed relatively to the nearest block container's width, not height */
/* cf. https://developer.mozilla.org/en-US/docs/Web/CSS/margin-bottom */
margin: calc(17/1080*var(--reverse-aspect-ratio)*100%) calc(60/1920*100%);
display: flex;
align-items: center;
display: flex;
align-items: center;
font-family: IDFVoyageur-bold;
font-family: IDFVoyageur-bold;
}
.header {
@extend %header;
@extend %header;
}
%title {
height: 50%;
width: 70%;
height: 50%;
width: 70%;
margin-right: auto;
margin-right: auto;
}
/* Idfm: 1860x892px (margin: 0px 30px) */
%body {
width: calc(1860/1920*100%);
height: calc(892/1080*100%);
margin: 0 calc(30/1920*100%);
width: calc(1860/1920*100%);
height: calc(892/1080*100%);
margin: 0 calc(30/1920*100%);
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
background-color: white;
background-color: white;
border-collapse:separate;
// border:solid var(--idfm-black) 1px;
border-radius: calc(15/1920*100%);
border-collapse:separate;
// border:solid var(--idfm-black) 1px;
border-radius: calc(15/1920*100%);
}
/* Idfm: 1800x54px (margin: 0px 50px) */
%footer {
width: calc(1820/1920*100%);
height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%);
width: calc(1820/1920*100%);
height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%);
display: flex;
align-items: center;
justify-content: right;
display: flex;
align-items: center;
justify-content: right;
}
.footer {
@extend %footer;
@extend %footer;
}
.footer div {
aspect-ratio: 1;
height: 50%;
aspect-ratio: 1;
height: 50%;
margin-left: calc(42/1920*100%);
margin-left: calc(42/1920*100%);
}

View File

@@ -1,31 +1,31 @@
%transportMode {
aspect-ratio : 1 / 1;
aspect-ratio : 1 / 1;
}
%linePicto {
font-family: IDFVoyageur-bold;
font-family: IDFVoyageur-bold;
}
%tramLinePicto {
@extend %linePicto;
@extend %linePicto;
aspect-ratio : 1 / 1;
aspect-ratio : 1 / 1;
}
%trainLinePicto {
@extend %linePicto;
@extend %linePicto;
aspect-ratio : 1 / 1;
aspect-ratio : 1 / 1;
}
%metroLinePicto {
@extend %linePicto;
@extend %linePicto;
aspect-ratio : 1 / 1;
aspect-ratio : 1 / 1;
}
%busLinePicto {
@extend %linePicto;
@extend %linePicto;
aspect-ratio : 2.25;
aspect-ratio : 2.25;
}

View File

@@ -4,40 +4,36 @@ import { createStore } from "solid-js/store";
import { Stop } from './types';
export interface AppContextStore {
getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void;
};
getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void;
}
export const AppContextContext = createContext<AppContextStore>();
export function AppContextProvider(props: { children: JSX.Element }) {
type Store = {
displayedStops: Stop[];
};
type Store = {
displayedStops: Stop[];
};
const [store, setStore] = createStore<Store>({
displayedStops: [],
});
const [store, setStore] = createStore<Store>({
displayedStops: [],
});
const getDisplayedStops = (): Stop[] => {
return store.displayedStops;
}
const getDisplayedStops = (): Stop[] => {
return store.displayedStops;
}
const setDisplayedStops = (stops: Stop[]): void => {
console.log("setDisplayedStops=", stops);
// setStore((s: Store) => {
setStore('displayedStops', stops);
// return s;
// });
}
const setDisplayedStops = (stops: Stop[]): void => {
console.log("setDisplayedStops=", stops);
setStore('displayedStops', stops);
}
return (
<AppContextContext.Provider value={{
getDisplayedStops, setDisplayedStops,
}}>
{props.children}
</AppContextContext.Provider>
);
};
return (
<AppContextContext.Provider value={{
getDisplayedStops, setDisplayedStops,
}}>
{props.children}
</AppContextContext.Provider>
);
}

View File

@@ -6,246 +6,235 @@ 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[]>;
getLineDestinations: (lineId: string) => string[];
getDestinationPassages: (lineId: string, destination: string) => Passage[];
getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>;
getLineDestinations: (lineId: string) => string[];
getDestinationPassages: (lineId: string, destination: string) => Passage[];
passages: () => Passages;
getPassagesLineIds: () => string[];
refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void;
clearPassages: () => void;
passages: () => Passages;
getPassagesLineIds: () => string[];
refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void;
clearPassages: () => void;
getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>;
getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>;
getStopDestinations: (stopId: number) => Promise<StopDestinations | undefined>;
getStopShape: (stopId: number) => Promise<StopShape | undefined>;
};
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://carrramba.adrien.run/api");
const [serverUrl] = createSignal<string>("https://carrramba.adrien.run/api");
type Store = {
lines: Lines;
passages: Passages;
stops: Stops;
stopShapes: StopShapes;
};
type Store = {
lines: Lines;
passages: Passages;
stops: Stops;
stopShapes: StopShapes;
};
const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {}, stopShapes: {} });
const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {}, stopShapes: {} });
const getLine = async (lineId: string): Promise<Line> => {
let line = store.lines[lineId];
if (line === undefined) {
console.log(`${lineId} not found... fetch it from backend.`);
const getLine = async (lineId: string): Promise<Line> => {
let line = store.lines[lineId];
if (line === undefined) {
console.log(`${lineId} not found... fetch it from backend.`);
const response = await fetch(`${serverUrl()}/line/${lineId}`, {
headers: { 'Content-Type': 'application/json' }
});
const response = await fetch(`${serverUrl()}/line/${lineId}`, {
headers: { 'Content-Type': 'application/json' }
});
const json = await response.json();
const json = await response.json();
if (response.ok) {
setStore('lines', lineId, json);
line = json;
}
else {
console.warn(`No line found for ${lineId} line id:`, json);
}
}
return line;
}
if (response.ok) {
setStore('lines', lineId, json);
line = json;
}
else {
console.warn(`No line found for ${lineId} line id:`, json);
}
}
return line;
}
const getLinePassages = (lineId: string): Record<string, Passage[]> => {
return store.passages[lineId];
}
const getLinePassages = (lineId: string): Record<string, Passage[]> => {
return store.passages[lineId];
}
const getLineDestinations = (lineId: string): string[] => {
return Object.keys(store.passages[lineId]);
}
const getLineDestinations = (lineId: string): string[] => {
return Object.keys(store.passages[lineId]);
}
// TODO: Remove this method: it's based on the next passages and return nothing until the refreshPassages is called.
const getDestinationPassages = (lineId: string, destination: string): Passage[] => {
return store.passages[lineId][destination];
}
// TODO: Remove this method: it's based on the next passages and return nothing until the refreshPassages is called.
const getDestinationPassages = (lineId: string, destination: string): Passage[] => {
return store.passages[lineId][destination];
}
const passages = (): Passages => {
return store.passages;
}
const passages = (): Passages => {
return store.passages;
}
const getPassagesLineIds = (): string[] => {
return Object.keys(store.passages);
}
const getPassagesLineIds = (): string[] => {
return Object.keys(store.passages);
}
const _cleanupPassages = (passages: Passages): void => {
const deadline = Math.floor(Date.now() / 1000) - 60;
for (const linePassages of Object.values(passages)) {
for (const destination of Object.keys(linePassages)) {
const destinationPassages = linePassages[destination];
const cleaned = [];
for (const passage of destinationPassages) {
if (passage.expectedDepartTs > deadline) {
cleaned.push(passage);
}
}
linePassages[destination] = cleaned;
}
}
}
const _cleanupPassages = (passages: Passages): void => {
const deadline = Math.floor(Date.now() / 1000) - 60;
for (const linePassages of Object.values(passages)) {
for (const destination of Object.keys(linePassages)) {
const destinationPassages = linePassages[destination];
const cleaned = [];
for (const passage of destinationPassages) {
if (passage.expectedDepartTs > deadline) {
cleaned.push(passage);
}
}
linePassages[destination] = cleaned;
}
}
}
const refreshPassages = async (stopId: number): Promise<void> => {
console.log(`Fetching data for ${stopId}`);
const httpOptions = { headers: { "Content-Type": "application/json" } };
const response = await fetch(`${serverUrl()}/stop/${stopId}/nextPassages`, httpOptions);
const refreshPassages = async (stopId: number): Promise<void> => {
console.log(`Fetching data for ${stopId}`);
const httpOptions = { headers: { "Content-Type": "application/json" } };
const response = await fetch(`${serverUrl()}/stop/${stopId}/nextPassages`, httpOptions);
const json = await response.json();
const json = await response.json();
if (response.ok) {
_cleanupPassages(json.passages);
addPassages(json.passages);
}
else {
console.warn(`No passage found for ${stopId} stop:`, json);
}
}
if (response.ok) {
_cleanupPassages(json.passages);
addPassages(json.passages);
}
else {
console.warn(`No passage found for ${stopId} stop:`, json);
}
}
const addPassages = (passages: Passages): void => {
batch(() => {
const storePassages = store.passages;
for (const lineId of Object.keys(passages)) {
const newLinePassages = passages[lineId];
const linePassages = storePassages[lineId];
if (linePassages === undefined) {
setStore('passages', lineId, newLinePassages);
}
else {
for (const destination of Object.keys(newLinePassages)) {
const newLinePassagesDestination = newLinePassages[destination];
const linePassagesDestination = linePassages[destination];
if (linePassagesDestination === undefined) {
setStore('passages', lineId, destination, newLinePassagesDestination);
}
else {
if (linePassagesDestination.length - newLinePassagesDestination.length != 0) {
console.log(`Server provides ${newLinePassagesDestination.length} passages, \
const addPassages = (passages: Passages): void => {
batch(() => {
const storePassages = store.passages;
for (const lineId of Object.keys(passages)) {
const newLinePassages = passages[lineId];
const linePassages = storePassages[lineId];
if (linePassages === undefined || Object.keys(linePassages).length == 0) {
setStore('passages', lineId, newLinePassages);
}
else {
for (const destination of Object.keys(newLinePassages)) {
const newLinePassagesDestination = newLinePassages[destination];
const linePassagesDestination = linePassages[destination];
if (linePassagesDestination === undefined) {
setStore('passages', lineId, destination, newLinePassagesDestination);
}
else {
if (linePassagesDestination.length - newLinePassagesDestination.length != 0) {
console.log(`Server provides ${newLinePassagesDestination.length} passages, \
${linePassagesDestination.length} here... refresh all them.`);
setStore('passages', lineId, destination, newLinePassagesDestination);
}
else {
linePassagesDestination.forEach((passage, index) => {
const newPassage = newLinePassagesDestination[index];
if (passage.expectedDepartTs != newPassage.expectedDepartTs) {
console.log(`Refresh expectedDepartTs (${passage.expectedDepartTs} -> ${newPassage.expectedDepartTs}`);
setStore('passages', lineId, destination, index, 'expectedDepartTs', newPassage.expectedDepartTs);
}
if (passage.expectedArrivalTs != newPassage.expectedArrivalTs) {
console.log(`Refresh expectedArrivalTs (${passage.expectedArrivalTs} -> ${newPassage.expectedArrivalTs}`);
setStore('passages', lineId, destination, index, 'expectedArrivalTs', newPassage.expectedArrivalTs);
}
});
}
}
}
}
}
});
}
setStore('passages', lineId, destination, newLinePassagesDestination);
}
else {
linePassagesDestination.forEach((passage, index) => {
const newPassage = newLinePassagesDestination[index];
if (passage.expectedDepartTs != newPassage.expectedDepartTs) {
console.log(`Refresh expectedDepartTs (${passage.expectedDepartTs} -> ${newPassage.expectedDepartTs}`);
setStore('passages', lineId, destination, index, 'expectedDepartTs', newPassage.expectedDepartTs);
}
if (passage.expectedArrivalTs != newPassage.expectedArrivalTs) {
console.log(`Refresh expectedArrivalTs (${passage.expectedArrivalTs} -> ${newPassage.expectedArrivalTs}`);
setStore('passages', lineId, destination, index, 'expectedArrivalTs', newPassage.expectedArrivalTs);
}
});
}
}
}
}
}
});
}
const clearPassages = (): void => {
setStore((s: Store): Store => {
for (const lineId of Object.keys(s.passages)) {
setStore('passages', lineId, undefined);
}
return s;
});
}
const clearPassages = (): void => {
setStore((s: Store): Store => {
for (const lineId of Object.keys(s.passages)) {
setStore('passages', lineId, {});
}
return s;
});
}
const getStop = (stopId: number): Stop | undefined => {
return store.stops[stopId];
}
const getStop = (stopId: number): Stop | undefined => {
return store.stops[stopId];
}
const searchStopByName = async (name: string): Promise<Stops> => {
const byIdStops: Stops = {};
const searchStopByName = async (name: string): Promise<Stops> => {
const byIdStops: Stops = {};
const response = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' }
});
const response = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' }
});
const json = await response.json();
const json = await response.json();
if (response.ok) {
for (const stop of json) {
byIdStops[stop.id] = stop;
setStore('stops', stop.id, stop);
if (response.ok) {
for (const stop of json) {
byIdStops[stop.id] = stop;
setStore('stops', stop.id, stop);
if (stop.stops !== undefined) {
for (const innerStop of stop.stops) {
setStore('stops', innerStop.id, innerStop);
}
}
}
}
else {
console.warn(`No stop found for '${name}' query:`, json);
}
if (stop.stops !== undefined) {
for (const innerStop of stop.stops) {
setStore('stops', innerStop.id, innerStop);
}
}
}
}
else {
console.warn(`No stop found for '${name}' query:`, json);
}
return byIdStops;
}
return byIdStops;
}
const getStopDestinations = async (stopId: number): Promise<StopDestinations | undefined> => {
const response = await fetch(`${serverUrl()}/stop/${stopId}/destinations`, {
headers: { 'Content-Type': 'application/json' }
});
const destinations = response.ok ? await response.json() : undefined;
return destinations;
}
const getStopDestinations = async (stopId: number): Promise<StopDestinations | undefined> => {
const response = await fetch(`${serverUrl()}/stop/${stopId}/destinations`, {
headers: { 'Content-Type': 'application/json' }
});
const destinations = response.ok ? await response.json() : undefined;
return destinations;
}
const getStopShape = async (stopId: number): Promise<StopShape | undefined> => {
let shape = store.stopShapes[stopId];
if (shape === undefined) {
console.log(`No shape found for ${stopId} stop... fetch it from backend.`);
const response = await fetch(`${serverUrl()}/stop/${stopId}/shape`, {
headers: { 'Content-Type': 'application/json' }
});
const getStopShape = async (stopId: number): Promise<StopShape | undefined> => {
let shape = store.stopShapes[stopId];
if (shape === undefined) {
console.log(`No shape found for ${stopId} stop... fetch it from backend.`);
const response = await fetch(`${serverUrl()}/stop/${stopId}/shape`, {
headers: { 'Content-Type': 'application/json' }
});
const json = await response.json();
const json = await response.json();
if (response.ok) {
setStore('stopShapes', stopId, json);
shape = json;
}
else {
console.warn(`No shape found for ${stopId} stop:`, json);
}
}
return shape;
}
if (response.ok) {
setStore('stopShapes', stopId, json);
shape = json;
}
else {
console.warn(`No shape found for ${stopId} stop:`, json);
}
}
return shape;
}
return (
<BusinessDataContext.Provider value={{
getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds,
refreshPassages, addPassages, clearPassages,
getStop, getStopDestinations, getStopShape, searchStopByName
}}>
{props.children}
</BusinessDataContext.Provider>
);
return (
<BusinessDataContext.Provider value={{
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

@@ -2,15 +2,16 @@ import { VoidComponent } from "solid-js";
// Inspired by https://github.com/hope-ui/hope-ui/blob/main/apps/docs/src/icons/IconHamburgerMenu.tsx
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"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
/>
</svg>);
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"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
/>
</svg>);
}

View File

@@ -1,26 +1,27 @@
@font-face {
font-family: IDFVoyageur-regular;
src: url(/public/fonts/IDFVoyageur-Regular.otf)
font-family: IDFVoyageur-regular;
src: url(/public/fonts/IDFVoyageur-Regular.otf)
}
@font-face {
font-family: IDFVoyageur-bold;
src: url(/public/fonts/IDFVoyageur-Bold.otf);
font-family: IDFVoyageur-bold;
src: url(/public/fonts/IDFVoyageur-Bold.otf);
}
@font-face {
font-family: IDFVoyageur-medium;
src: url(/public/fonts/IDFVoyageur-Medium.otf);
font-family: IDFVoyageur-medium;
src: url(/public/fonts/IDFVoyageur-Medium.otf);
}
html, body {
aspect-ratio: 16/9;
height: 100vh;
aspect-ratio: 16/9;
margin: 0;
margin: 0;
font-family: IDFVoyageur;
font-family: IDFVoyageur;
}
#root {
width: inherit;
width: inherit;
}

View File

@@ -3,65 +3,65 @@
.passagesDisplay {
@extend %widget;
@extend %widget;
.header {
$header-element-height: calc(80/100*100%);
$component-border: solid var(--idfm-white) calc(0.25*1vh);
$component-border-radius: calc(9/86*100%);
.transportMode {
@extend %transportMode;
.header {
$header-element-height: calc(80/100*100%);
$component-border: solid var(--idfm-white) calc(0.25*1vh);
$component-border-radius: calc(9/86*100%);
.transportMode {
@extend %transportMode;
height: 100%;
margin: 0;
margin-right: calc(23/1920*100%);
}
height: 100%;
margin: 0;
margin-right: calc(23/1920*100%);
}
.title {
@extend %title;
}
.title {
@extend %title;
}
.menu {
aspect-ratio: 0.75;
height: $header-element-height;
.menu {
aspect-ratio: 0.75;
height: $header-element-height;
margin-right: calc(30/1920*100%);
margin-left: auto;
border: $component-border;
border-radius: $component-border-radius;
margin-right: calc(30/1920*100%);
margin-left: auto;
border: $component-border;
border-radius: $component-border-radius;
button {
height: 100%;
button {
height: 100%;
border: 0;
color: var(--idfm-white);
background-color: transparent;
border: 0;
color: var(--idfm-white);
background-color: transparent;
.iconHamburgerMenu {
width: 75%;
}
}
}
.iconHamburgerMenu {
width: 75%;
}
}
}
.clock {
width: calc(175/1920*100%);
height: $header-element-height;
.clock {
width: calc(175/1920*100%);
height: $header-element-height;
display: flex;
align-items: center;
justify-content: center;
display: flex;
align-items: center;
justify-content: center;
border: $component-border;
border-radius: $component-border-radius;
border: $component-border;
border-radius: $component-border-radius;
svg {
aspect-ratio: 2.45;
height: calc(0.7*100%);
}
}
}
svg {
aspect-ratio: 2.45;
height: calc(0.7*100%);
}
}
}
.body {
@extend %body
}
.body {
@extend %body
}
}

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,290 +8,273 @@ import { AppContextContext, AppContextStore } from "./appContext";
import { getTransportModeSrc, PositionedPanel } from "./utils";
import { PassagesPanel } from "./passagesPanel";
import { IconHamburgerMenu } from './extra/iconHamburgerMenu';
import "./passagesDisplay.scss";
interface PassagesDisplayStore {
isPassagesRefreshEnabled: () => boolean;
enablePassagesRefresh: () => void;
disablePassagesRefresh: () => void;
togglePassagesRefresh: () => void;
isPassagesRefreshEnabled: () => boolean;
enablePassagesRefresh: () => void;
disablePassagesRefresh: () => void;
togglePassagesRefresh: () => void;
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
};
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
}
const PassagesDisplayContext = createContext<PassagesDisplayStore>();
function PassagesDisplayProvider(props: { children: JSX.Element }) {
type Store = {
refreshEnabled: boolean;
panels: PositionedPanel[];
displayedPanelId: number;
};
type Store = {
refreshEnabled: boolean;
panels: PositionedPanel[];
displayedPanelId: number;
};
const [store, setStore] = createStore<Store>({ refreshEnabled: true, panels: [], displayedPanelId: 0 });
const [store, setStore] = createStore<Store>({ refreshEnabled: true, panels: [], displayedPanelId: 0 });
const isPassagesRefreshEnabled = (): boolean => {
return store.refreshEnabled;
}
const isPassagesRefreshEnabled = (): boolean => {
return store.refreshEnabled;
}
const enablePassagesRefresh = (): void => {
setStore('refreshEnabled', true);
}
const enablePassagesRefresh = (): void => {
setStore('refreshEnabled', true);
}
const disablePassagesRefresh = (): void => {
setStore('refreshEnabled', false);
}
const disablePassagesRefresh = (): void => {
setStore('refreshEnabled', false);
}
const togglePassagesRefresh = (): void => {
setStore('refreshEnabled', !store.refreshEnabled);
}
const togglePassagesRefresh = (): void => {
setStore('refreshEnabled', !store.refreshEnabled);
}
const getPanels = (): PositionedPanel[] => {
return store.panels;
}
const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels);
}
const getPanels = (): PositionedPanel[] => {
return store.panels;
}
const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels);
}
const getDisplayedPanelId = (): number => {
return store.displayedPanelId;
}
const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId);
}
const getDisplayedPanelId = (): number => {
return store.displayedPanelId;
}
const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId);
}
return (
<PassagesDisplayContext.Provider value={{
isPassagesRefreshEnabled, enablePassagesRefresh,
disablePassagesRefresh, togglePassagesRefresh,
getPanels, setPanels,
getDisplayedPanelId, setDisplayedPanelId
}}>
{props.children}
</PassagesDisplayContext.Provider>
);
return (
<PassagesDisplayContext.Provider value={{
isPassagesRefreshEnabled, enablePassagesRefresh,
disablePassagesRefresh, togglePassagesRefresh,
getPanels, setPanels,
getDisplayedPanelId, setDisplayedPanelId
}}>
{props.children}
</PassagesDisplayContext.Provider>
);
}
// TODO: Sort transport modes by weight
const Header: VoidComponent<{ title: string }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (businessDataStore === undefined || passagesDisplayStore === undefined)
return <div />;
if (businessDataStore === undefined || passagesDisplayStore === undefined)
return <div />;
const { getLine, passages } = businessDataStore;
const { isPassagesRefreshEnabled, togglePassagesRefresh } = passagesDisplayStore;
const { getLine, passages } = businessDataStore;
const [dateNow] = createDateNow(1000);
const [dateNow] = createDateNow(1000);
const computeTransportModes = async (lineIds: string[]): Promise<string[]> => {
const lines = await Promise.all(lineIds.map((lineId) => getLine(lineId)));
const urls: Set<string> = new Set();
for (const line of lines) {
const src = getTransportModeSrc(line.transportMode, false);
if (src !== undefined) {
urls.add(src);
}
}
return Array.from(urls);
}
const [linesIds, setLinesIds] = createSignal<string[]>([]);
const [transportModeUrls] = createResource<string[], string[]>(linesIds, computeTransportModes);
const computeTransportModes = async (lineIds: string[]): Promise<string[]> => {
const lines = await Promise.all(lineIds.map((lineId) => getLine(lineId)));
const urls: Set<string> = new Set();
for (const line of lines) {
const src = getTransportModeSrc(line.transportMode, false);
if (src !== undefined) {
urls.add(src);
}
}
return Array.from(urls);
}
const [linesIds, setLinesIds] = createSignal<string[]>([]);
const [transportModeUrls] = createResource<string[], string[]>(linesIds, computeTransportModes);
createEffect(() => {
setLinesIds(Object.keys(passages()));
});
createEffect(() => {
setLinesIds(Object.keys(passages()));
});
return (
<div class="header">
<Show when={transportModeUrls() !== undefined} >
<For each={transportModeUrls()}>
{(url) =>
<div class="transportMode">
<img src={url} />
</div>
}
</For>
</Show>
<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="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">
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="43" style="fill: #ffffff">
{format(dateNow(), "HH:mm")}
</text>
</svg>
</div>
</div >
);
return <div class="header">
<Show when={transportModeUrls() !== undefined} >
<For each={transportModeUrls()}>
{(url) =>
<div class="transportMode">
<img src={url} />
</div>
}</For>
</Show>
<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="menu">
</div>
<div class="clock">
<svg viewBox="0 0 115 43">
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="43" style="fill: #ffffff">
{format(dateNow(), "HH:mm")}
</text>
</svg>
</div>
</div>;
};
const Footer: VoidComponent<{}> = () => {
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (passagesDisplayStore === undefined)
return <div />;
if (passagesDisplayStore === undefined)
return <div />;
const { getDisplayedPanelId, getPanels } = passagesDisplayStore;
const { getDisplayedPanelId, getPanels } = passagesDisplayStore;
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>
);
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>
);
}
const Body: ParentComponent<{ maxPassagesPerPanel: number, syncPeriodMsec: number, panelSwitchPeriodMsec: number }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (businessDataStore === undefined || appContextStore === undefined || passagesDisplayStore === undefined) {
return <div />;
}
if (businessDataStore === undefined || appContextStore === undefined || passagesDisplayStore === undefined) {
return <div />;
}
const { getLineDestinations, passages, getPassagesLineIds, clearPassages, refreshPassages } = businessDataStore;
const { isPassagesRefreshEnabled, getDisplayedPanelId, setDisplayedPanelId, getPanels, setPanels } = passagesDisplayStore;
const { getDisplayedStops } = appContextStore;
const { getLineDestinations, passages, getPassagesLineIds, clearPassages, refreshPassages } = businessDataStore;
const { isPassagesRefreshEnabled, getDisplayedPanelId, setDisplayedPanelId, getPanels, setPanels } = passagesDisplayStore;
const { getDisplayedStops } = appContextStore;
setInterval(() => {
let nextPanelId = getDisplayedPanelId() + 1;
if (nextPanelId >= getPanels().length) {
nextPanelId = 0;
}
setDisplayedPanelId(nextPanelId);
}, props.panelSwitchPeriodMsec);
setInterval(() => {
let nextPanelId = getDisplayedPanelId() + 1;
if (nextPanelId >= getPanels().length) {
nextPanelId = 0;
}
setDisplayedPanelId(nextPanelId);
}, props.panelSwitchPeriodMsec);
setInterval(
async () => {
if (isPassagesRefreshEnabled()) {
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
}
else {
console.log("Passages refresh disabled... skip it.");
}
},
props.syncPeriodMsec
);
setInterval(
async () => {
if (isPassagesRefreshEnabled()) {
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
}
else {
console.log("Passages refresh disabled... skip it.");
}
},
props.syncPeriodMsec
);
createEffect(() => {
console.log("######### onStopIdUpdate #########");
// Track local.stopIp to force dependency.
console.log("getDisplayedStop=", getDisplayedStops());
clearPassages();
});
createEffect(() => {
console.log("######### onStopIdUpdate #########");
// Track local.stopIp to force dependency.
console.log("getDisplayedStop=", getDisplayedStops());
clearPassages();
});
createEffect(async () => {
console.log(`## OnPassageUpdate ${passages()} ##`);
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
});
createEffect(async () => {
console.log(`## OnPassageUpdate ${passages()} ##`);
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
});
return (
<div class="body">
{() => {
setPanels([]);
return (
<div class="body">
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionedPanel[] = [];
let index = 0;
let newPanels = [];
let positioneds: PositionedPanel[] = [];
let index = 0;
let lineIds: string[] = [];
let destinationsNb = 0;
let lineIds: string[] = [];
let destinationsNb = 0;
for (const lineId of getPassagesLineIds()) {
const lineDestinations = getLineDestinations(lineId);
for (const lineId of getPassagesLineIds()) {
const lineDestinations = getLineDestinations(lineId);
if (lineDestinations.length <= props.maxPassagesPerPanel - destinationsNb) {
lineIds.push(lineId);
destinationsNb += lineDestinations.length;
}
else {
const panelid = index++;
const panel = <PassagesPanel stopId={getDisplayedStops()[0].id} lineIds={lineIds} show={panelid == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelid, panel: panel });
if (lineDestinations.length <= props.maxPassagesPerPanel - destinationsNb) {
lineIds.push(lineId);
destinationsNb += lineDestinations.length;
}
else {
const panelid = index++;
const panel = <PassagesPanel stopId={getDisplayedStops()[0].id} lineIds={lineIds} show={panelid == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelid, panel: panel });
lineIds = [lineId];
destinationsNb = lineDestinations.length;
}
}
if (destinationsNb) {
const panelId = index++;
const panel = <PassagesPanel stopId={getDisplayedStops()[0].id} lineIds={lineIds} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
}
lineIds = [lineId];
destinationsNb = lineDestinations.length;
}
}
if (destinationsNb) {
const panelId = index++;
const panel = <PassagesPanel stopId={getDisplayedStops()[0].id} lineIds={lineIds} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
}
setPanels(positioneds);
setPanels(positioneds);
return newPanels;
}}
</div>
);
return newPanels;
}}
</div>
);
}
export const PassagesDisplay: ParentComponent = () => {
const MAX_PASSAGES_PER_PANEL = 5;
const MAX_PASSAGES_PER_PANEL = 5;
// TODO: Use props.
const syncPeriodMsec = 20 * 1000;
const panelSwitchPeriodMsec = 4 * 1000;
// TODO: Use props.
const syncPeriodMsec = 20 * 1000;
const panelSwitchPeriodMsec = 4 * 1000;
return (
<div class="passagesDisplay">
<PassagesDisplayProvider>
<Header title="Prochains passages" />
<Body maxPassagesPerPanel={MAX_PASSAGES_PER_PANEL} syncPeriodMsec={syncPeriodMsec} panelSwitchPeriodMsec={panelSwitchPeriodMsec} />
<Footer />
</PassagesDisplayProvider>
</div>
);
return <div class="passagesDisplay">
<PassagesDisplayProvider>
<Header title="Prochains passages" />
<Body maxPassagesPerPanel={MAX_PASSAGES_PER_PANEL} syncPeriodMsec={syncPeriodMsec} panelSwitchPeriodMsec={panelSwitchPeriodMsec} />
<Footer />
</PassagesDisplayProvider>
</div>;
};

View File

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

View File

@@ -12,169 +12,158 @@ import "./passagesPanel.scss";
const UnavailablePassage: VoidComponent<{ style: string }> = (props) => {
const textStyle = { fill: "#000000" };
const textStyle = { fill: "#000000" };
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>
);
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>;
}
const Platform: VoidComponent<{ name: string }> = (props) => {
const platformTextPaddingPx: number = 20;
const viewBoxWidthPx: number = 215;
const platformTextPaddingPx: number = 20;
const viewBoxWidthPx: number = 215;
let rectRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined;
let rectRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined;
onMount(() => {
if (rectRef !== undefined && textRef !== undefined) {
const textWidth = textRef.getComputedTextLength();
const rectWidth = textWidth + platformTextPaddingPx * 2;
rectRef.setAttribute("width", `${rectWidth}px`);
rectRef.setAttribute("x", `${viewBoxWidthPx - rectWidth}px`);
textRef.setAttribute("x", `${viewBoxWidthPx - platformTextPaddingPx}px`);
}
});
onMount(() => {
if (rectRef !== undefined && textRef !== undefined) {
const textWidth = textRef.getComputedTextLength();
const rectWidth = textWidth + platformTextPaddingPx * 2;
rectRef.setAttribute("width", `${rectWidth}px`);
rectRef.setAttribute("x", `${viewBoxWidthPx - rectWidth}px`);
textRef.setAttribute("x", `${viewBoxWidthPx - platformTextPaddingPx}px`);
}
});
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>
);
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>;
}
const TtwPassage: VoidComponent<{
line: Line, destination: string, index: number, style: string,
withPlatformStyle: string, fontSize: number, fallbackStyle: string
line: Line, destination: string, index: number, style: string,
withPlatformStyle: string, fontSize: number, fallbackStyle: string
}> = (props) => {
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined)
return <div />;
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined)
return <div />;
const { getDestinationPassages } = businessDataContext;
const { getDestinationPassages } = businessDataContext;
const [dateNow] = createDateNow(10000);
const [dateNow] = createDateNow(10000);
const transition: AnimationOptions = { duration: 3, repeat: Infinity };
const transition: AnimationOptions = { duration: 3, repeat: Infinity };
return (() => {
const passage = getDestinationPassages(props.line.id, props.destination)[props.index];
return (() => {
const passage = getDestinationPassages(props.line.id, props.destination)[props.index];
const refTs = passage !== undefined ? (passage.expectedDepartTs !== null ? passage.expectedDepartTs : passage.expectedArrivalTs) : 0;
const ttwSec = refTs - (getTime(dateNow()) / 1000);
const ttwRepr = ttwSec < 3600 ? `${Math.floor(ttwSec / 60).toString().padStart(2, "0")} min` : format(refTs * 1000, "HH:mm");
const isApproaching = ttwSec <= 60;
const refTs = passage !== undefined ? (passage.expectedDepartTs !== null ? passage.expectedDepartTs : passage.expectedArrivalTs) : 0;
const ttwSec = refTs - (getTime(dateNow()) / 1000);
const ttwRepr = ttwSec < 3600 ? `${Math.floor(ttwSec / 60).toString().padStart(2, "0")} min` : format(refTs * 1000, "HH:mm");
const isApproaching = ttwSec <= 60;
const text = <svg class="passage" viewBox={`0 0 215 ${props.fontSize}`}>
<Motion.text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={props.fontSize} style={{ fill: "#000000" }}
initial={isApproaching ? undefined : false}
animate={{ opacity: [1, 0, 1] }}
transition={transition}>
{ttwRepr}
</Motion.text>
</svg>;
const text = <svg class="passage" viewBox={`0 0 215 ${props.fontSize}`}>
<Motion.text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={props.fontSize} style={{ fill: "#000000" }}
initial={isApproaching ? undefined : false}
animate={{ opacity: [1, 0, 1] }}
transition={transition}>
{ttwRepr}
</Motion.text>
</svg>;
return (
<Show when={passage !== undefined} fallback=<UnavailablePassage style={props.fallbackStyle} />>
<Show
when={passage.arrivalPlatformName !== null}
fallback={
<div class={props.style}>
{text}
</div>}>
<div class={props.withPlatformStyle}>
{text}
<Platform name={passage.arrivalPlatformName} />
</div>
</Show >
</Show >
);
});
return <Show when={passage !== undefined} fallback={<UnavailablePassage style={props.fallbackStyle} />}>
<Show
when={passage.arrivalPlatformName !== null}
fallback={
<div class={props.style}>
{text}
</div>}>
<div class={props.withPlatformStyle}>
{text}
<Platform name={passage.arrivalPlatformName} />
</div>
</Show>
</Show>;
});
}
/* TODO: Manage end of service */
const DestinationPassages: VoidComponent<{ line: Line, destination: string }> = (props) => {
/* TODO: Find where to get data to compute traffic status. */
const trafficStatusColor = new Map<TrafficStatus, string>([
[TrafficStatus.UNKNOWN, "#ffffff"],
[TrafficStatus.FLUID, "#00643c"],
[TrafficStatus.DISRUPTED, "#ffbe00"],
[TrafficStatus.VERY_DISRUPTED, "#ff5a00"],
[TrafficStatus.BYPASSED, "#ffffff"]
]);
/* TODO: Find where to get data to compute traffic status. */
const trafficStatusColor = new Map<TrafficStatus, string>([
[TrafficStatus.UNKNOWN, "#ffffff"],
[TrafficStatus.FLUID, "#00643c"],
[TrafficStatus.DISRUPTED, "#ffbe00"],
[TrafficStatus.VERY_DISRUPTED, "#ff5a00"],
[TrafficStatus.BYPASSED, "#ffffff"]
]);
// TODO: Manage traffic status
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
// TODO: Manage traffic status
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
return (
<div class="line">
<div class="transportMode">
{renderLineTransportMode(props.line)}
</div>
{renderLinePicto(props.line)}
<div class="destination">
<ScrollingText height={40} width={600} content={props.destination} />
</div>
<div class="trafficStatus">
<svg viewBox="0 0 51 51">
<circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} />
</svg>
</div>
<TtwPassage line={props.line} destination={props.destination} index={0}
style="firstPassage" withPlatformStyle="withPlatformFirstPassage"
fontSize={50} fallbackStyle="unavailableFirstPassage" />
<TtwPassage line={props.line} destination={props.destination} index={1}
style="secondPassage" withPlatformStyle="withPlatformSecondPassage"
fontSize={45} fallbackStyle="unavailableSecondPassage" />
</div >
);
return <div class="line">
<div class="transportMode">
{renderLineTransportMode(props.line)}
</div>
{renderLinePicto(props.line)}
<div class="destination">
<ScrollingText height={40} width={600} content={props.destination} />
</div>
<div class="trafficStatus">
<svg viewBox="0 0 51 51">
<circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} />
</svg>
</div>
<TtwPassage line={props.line} destination={props.destination} index={0}
style="firstPassage" withPlatformStyle="withPlatformFirstPassage"
fontSize={50} fallbackStyle="unavailableFirstPassage" />
<TtwPassage line={props.line} destination={props.destination} index={1}
style="secondPassage" withPlatformStyle="withPlatformSecondPassage"
fontSize={45} fallbackStyle="unavailableSecondPassage" />
</div>;
}
export type PassagesPanelComponentProps = ParentProps & { stopId: number, lineIds: string[], show: boolean };
export type PassagesPanelComponent = ParentComponent<PassagesPanelComponentProps>;
export const PassagesPanel: PassagesPanelComponent = (props) => {
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined)
return <div />;
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined)
return <div />;
const { getLine, getLineDestinations } = businessDataContext;
const { getLine, getLineDestinations } = businessDataContext;
const getLines = async (lineIds: string[]): Promise<Line[]> => {
const lines = await Promise.all<Promise<Line>[]>(lineIds.map((lineId) => getLine(lineId)));
return lines;
}
const [lines] = createResource<Line[], string[]>(props.lineIds, getLines);
const getLines = async (lineIds: string[]): Promise<Line[]> => {
const lines = await Promise.all<Promise<Line>[]>(lineIds.map((lineId) => getLine(lineId)));
return lines;
}
const [lines] = createResource<Line[], string[]>(props.lineIds, getLines);
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>
</Show>
}
</For>
</Show>
</div >
);
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>
</Show>
}</For>
</Show>
</div>;
}

View File

@@ -1,60 +1,58 @@
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>;
export interface SearchStore {
getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void;
getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void;
getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void;
getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void;
addMarkers: (stopId: number, markers: LeafletMarker[]) => void;
addMarkers: (stopId: number, markers: LeafletMarker[]) => void;
};
export const SearchContext = createContext<SearchStore>();
export function SearchProvider(props: { children: JSX.Element }) {
type Store = {
foundStops: Stop[];
markers: ByStopIdMarkers;
displayedStops: Stop[];
};
type Store = {
foundStops: Stop[];
markers: ByStopIdMarkers;
displayedStops: Stop[];
};
const [store, setStore] = createStore<Store>({ foundStops: [], markers: {}, displayedStops: [] });
const [store, setStore] = createStore<Store>({ foundStops: [], markers: {}, displayedStops: [] });
const getFoundStops = (): Stop[] => {
return store.foundStops;
}
const getFoundStops = (): Stop[] => {
return store.foundStops;
}
const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops);
}
const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops);
}
const getDisplayedStops = (): Stop[] => {
return store.displayedStops;
}
const getDisplayedStops = (): Stop[] => {
return store.displayedStops;
}
const setDisplayedStops = (stops: Stop[]): void => {
setStore((s: Store) => {
setStore('displayedStops', stops);
return s;
});
}
const setDisplayedStops = (stops: Stop[]): void => {
setStore((s: Store) => {
setStore('displayedStops', stops);
return s;
});
}
const addMarkers = (stopId: number, markers: L.Marker[]): void => {
setStore('markers', stopId, markers);
}
const addMarkers = (stopId: number, markers: L.Marker[]): void => {
setStore('markers', stopId, markers);
}
return (
<SearchContext.Provider value={{ getFoundStops, setFoundStops, getDisplayedStops, setDisplayedStops, addMarkers }}>
{props.children}
</SearchContext.Provider>
);
return <SearchContext.Provider value={{ getFoundStops, setFoundStops, getDisplayedStops, setDisplayedStops, addMarkers }}>
{props.children}
</SearchContext.Provider>;
}

View File

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

View File

@@ -24,192 +24,190 @@ 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 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 { getStop } = businessDataStore;
const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore;
const [selectedMapStop, setSelectedMapStop] = createSignal<Stop | undefined>(undefined);
const [isPopupDisplayed, setPopupDisplayed] = createSignal<boolean>(false);
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];
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;
let popup: StopPopup | undefined = undefined;
const stopVectorSource = new OlVectorSource({ features: [] });
const stopVectorLayer = new OlVectorLayer({ source: stopVectorSource });
const [mapRef, setMapRef] = createSignal<HTMLDivElement>();
let overlay: OlOverlay | undefined = undefined;
let map: OlMap | undefined = undefined;
const stopVectorSource = new OlVectorSource({ features: [] });
const stopVectorLayer = new OlVectorLayer({ source: stopVectorSource });
const displayedFeatures: Record<number, OlFeature> = {};
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)
);
}
}
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 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 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)
);
}
}
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);
onMount(() => buildMap(mapDiv));
createEffect(() => {
map.setTarget(mapRef());
});
// Filling the map with stops shape
createEffect(() => {
const stops = getFoundStops();
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));
}
}
// Filling the map with stops shape
createEffect(() => {
const stops = getFoundStops();
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);
if (!foundStopIds.has(stopId)) {
console.log(`Remove feature for ${stopId}`);
stopVectorSource.removeFeature(feature);
delete displayedFeatures[stopId];
}
}
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 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 });
}
});
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);
}
});
}
}
});
// 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);
const flash = (feature: OlFeature) => {
const start = Date.now();
const flashGeom = feature.getGeometry()?.clone();
const listenerKey = stopVectorLayer.on('postrender', animate);
// Force postrender raising.
feature.changed();
// Force postrender raising.
feature.changed();
function animate(event) {
const frameState = event.frameState;
const elapsed = frameState.time - start;
const vectorContext = getVectorContext(event);
function animate(event) {
const frameState = event.frameState;
const elapsed = frameState.time - start;
const vectorContext = getVectorContext(event);
if (elapsed >= flashDurationMs) {
unByKey(listenerKey);
return;
}
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);
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,
}),
}),
});
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);
vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom);
// tell OpenLayers to continue postrender animation
map.render();
}
}
}
// 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>
</>;
return <>
<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

@@ -11,75 +11,75 @@ 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 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 { 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 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 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 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);
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();
createEffect(() => {
const shape_ = shape();
if (shape_ === undefined) {
return;
}
if (shape_ === undefined) {
return;
}
let feature = undefined;
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);
}
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);
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);
});
setMapFeature(props.stop.id, feature);
});
return <For each={props.stop.stops}>{stop => <MapStop stop={stop} selected={props.selected} />}</For>;
return <For each={props.stop.stops}>{stop => <MapStop stop={stop} selected={props.selected} />}</For>;
}

View File

@@ -10,27 +10,27 @@ type ByStopIdMapFeatures = Record<number, OlFeature>;
export interface SearchStore {
getSearchText: () => string;
setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>;
getSearchText: () => string;
setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>;
getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void;
getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void;
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
getHighlightedStop: () => Stop | undefined;
setHighlightedStop: (stop: Stop) => void;
resetHighlightedStop: () => 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;
enableMap: (enable: boolean) => void;
isMapEnabled: () => boolean;
getMapFeature: (stopId: number) => OlFeature | undefined;
getAllMapFeatures: () => ByStopIdMapFeatures;
setMapFeature: (stopId: number, feature: OlFeature) => void;
};
export const SearchContext = createContext<SearchStore>();
@@ -38,128 +38,126 @@ export const SearchContext = createContext<SearchStore>();
export function SearchProvider(props: { children: JSX.Element }) {
const searchTextDelayMs = 1500;
const searchTextDelayMs = 1500;
type Store = {
searchText: string;
searchPromise: Promise<void> | undefined;
foundStops: Stop[];
displayedPanelId: number;
panels: PositionedPanel[];
highlightedStop: Stop | undefined;
mapEnabled: boolean;
mapFeatures: ByStopIdMapFeatures;
};
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 [store, setStore] = createStore<Store>({
searchText: "",
searchPromise: undefined,
foundStops: [],
displayedPanelId: 0,
panels: [],
highlightedStop: undefined,
// mapEnabled: false,
mapFeatures: {},
});
const getSearchText = (): string => {
return store.searchText;
}
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 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);
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);
}
}
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 getFoundStops = (): Stop[] => {
return store.foundStops;
}
const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops);
}
const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops);
}
const getDisplayedPanelId = (): number => {
return store.displayedPanelId;
}
const getDisplayedPanelId = (): number => {
return store.displayedPanelId;
}
const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId);
}
const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId);
}
const getPanels = (): PositionedPanel[] => {
return store.panels;
}
const getPanels = (): PositionedPanel[] => {
return store.panels;
}
const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels);
}
const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels);
}
const getHighlightedStop = (): Stop | undefined => {
return store.highlightedStop;
}
const getHighlightedStop = (): Stop | undefined => {
return store.highlightedStop;
}
const setHighlightedStop = (stop: Stop): void => {
setStore('highlightedStop', stop);
}
const setHighlightedStop = (stop: Stop): void => {
setStore('highlightedStop', stop);
}
const resetHighlightedStop = (): void => {
setStore('highlightedStop', undefined);
}
const resetHighlightedStop = (): void => {
setStore('highlightedStop', undefined);
}
const enableMap = (enable: boolean): void => {
setStore("mapEnabled", enable);
}
const enableMap = (enable: boolean): void => {
setStore("mapEnabled", enable);
}
const isMapEnabled = (): boolean => {
return store.mapEnabled;
}
const isMapEnabled = (): boolean => {
return store.mapEnabled;
}
const getAllMapFeatures = (): ByStopIdMapFeatures => {
return store.mapFeatures;
}
const getAllMapFeatures = (): ByStopIdMapFeatures => {
return store.mapFeatures;
}
const getMapFeature = (stopId: number): OlFeature | undefined => {
return store.mapFeatures[stopId];
}
const getMapFeature = (stopId: number): OlFeature | undefined => {
return store.mapFeatures[stopId];
}
const setMapFeature = (stopId: number, feature: OlFeature): void => {
setStore('mapFeatures', stopId, feature);
};
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>
);
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

@@ -3,98 +3,98 @@
.stopPanel {
scroll-snap-align: center;
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%);
.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;
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);
/* 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;
cursor: default;
font-family: IDFVoyageur-bold;
}
.name {
margin-left: calc(40/1920*100%);
width: 60%;
aspect-ratio: 2.5;
.lineRepr {
width: 40%;
aspect-ratio: 2.5;
display: flex;
align-items: center;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
font-family: IDFVoyageur-bold;
}
.transportMode {
@extend %transportMode;
.lineRepr {
width: 40%;
aspect-ratio: 2.5;
height: 50%;
}
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
.linesRepresentationMatrix {
@extend %busLinePicto; // Use the larger picto aspect-ratio
width: 75%;
aspect-ratio: 3;
.transportMode {
@extend %transportMode;
display: flex;
flex-flow: row;
flex-wrap: wrap;
height: 50%;
}
%picto {
margin-left: 1%;
align-self: center;
justify-self: center;
}
.linesRepresentationMatrix {
@extend %busLinePicto; // Use the larger picto aspect-ratio
width: 75%;
aspect-ratio: 3;
%singleLinePicto {
@extend %picto;
display: flex;
flex-flow: row;
flex-wrap: wrap;
height: 80%;
}
%picto {
margin-left: 1%;
align-self: center;
justify-self: center;
}
.transportMode {
@extend %transportMode;
@extend %picto;
}
%singleLinePicto {
@extend %picto;
.tramLinePicto {
@extend %tramLinePicto;
@extend %singleLinePicto;
}
height: 80%;
}
.trainLinePicto {
@extend %trainLinePicto;
@extend %singleLinePicto;
}
.transportMode {
@extend %transportMode;
@extend %picto;
}
.metroLinePicto {
@extend %metroLinePicto;
@extend %singleLinePicto;
}
.tramLinePicto {
@extend %tramLinePicto;
@extend %singleLinePicto;
}
.busLinePicto {
@extend %busLinePicto;
@extend %picto;
.trainLinePicto {
@extend %trainLinePicto;
@extend %singleLinePicto;
}
height: 40%;
}
}
}
}
.metroLinePicto {
@extend %metroLinePicto;
@extend %singleLinePicto;
}
.busLinePicto {
@extend %busLinePicto;
@extend %picto;
height: 40%;
}
}
}
}
}

View File

@@ -11,133 +11,124 @@ import "./stopPanel.scss";
const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 40;
const fontSize: number = 40;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const { getLine } = businessDataStore;
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 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);
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>
);
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>;
mode: JSX.Element | undefined;
lines: Record<string, JSX.Element | JSX.Element[] | undefined>;
}
const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 10;
const fontSize: number = 10;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
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 />;
if (businessDataStore === undefined || appContextStore === undefined || searchStore === undefined)
return <div />;
const { getLine } = businessDataStore;
const { setDisplayedStops } = appContextStore;
const { setHighlightedStop, resetHighlightedStop } = searchStore;
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 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 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);
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="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>;
}
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 [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>
);
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

@@ -5,45 +5,43 @@ 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 businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const { getLine, getStopDestinations } = businessDataStore;
const { getLine, getStopDestinations } = businessDataStore;
let popupDiv: HTMLDivElement | undefined = undefined;
let popupDiv: HTMLDivElement | undefined = undefined;
const getDestinations = async (stop: Stop): Promise<{ lineId: string, destinations: string[] }[]> => {
let ret = [];
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 });
}
}
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 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 >
);
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

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

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,185 +16,181 @@ 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>);
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);
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { setSearchText } = searchStore;
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);
}
}
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 >
);
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);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
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;
const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore;
let stopsPanelsRef: HTMLDivElement | undefined = undefined
const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef);
const yStopsPanelsScroll = () => stopsPanelsScroll.y;
createEffect(() => {
yStopsPanelsScroll();
createEffect(() => {
yStopsPanelsScroll();
for (const panel of getPanels()) {
const panelDiv = panel.panel();
const panelDivClientRect = panelDiv.getBoundingClientRect();
if (panelDivClientRect.y > 0) {
setDisplayedPanelId(panel.position);
break;
}
}
});
for (const panel of getPanels()) {
const panelDiv = panel.panel;
if (panelDiv != null) {
const panelDivClientRect = panelDiv.getBoundingClientRect();
if (panelDivClientRect.y > 0) {
setDisplayedPanelId(panel.position);
break;
}
}
}
});
return (
<div ref={stopsPanelsRef} class="stopsPanels">
{() => {
setPanels([]);
return (
<div ref={stopsPanelsRef} class="stopsPanels">
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionedPanel[] = [];
let newPanels = [];
let positioneds: PositionedPanel[] = [];
let stops: Stop[] = [];
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 });
}
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);
setPanels(positioneds);
return newPanels;
}}
</div>
);
return newPanels;
}}
</div>
);
};
const MapPlaceholder: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
if (searchStore === undefined) {
return <div />;
}
const { enableMap } = searchStore;
const { enableMap } = searchStore;
const onDoubleClick = (): void => {
console.log('!!! ON DOUBLE CLICK');
enableMap(true);
}
const onDoubleClick = (): void => {
console.log('!!! ON DOUBLE CLICK');
enableMap(true);
}
return <div
class="mapPlaceholder" ondblclick={() => onDoubleClick()}>
Double-clic pour activer la carte
</div>;
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 searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { isMapEnabled } = searchStore;
const { isMapEnabled } = searchStore;
const maxStopsPerPanel = 5;
const maxStopsPerPanel = 5;
return <div class="body">
<StopsPanels maxStopsPerPanel={maxStopsPerPanel} />
<Show when={isMapEnabled()} fallback={<MapPlaceholder />}>
<Map />
</Show>
</div>;
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 searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { getDisplayedPanelId, getPanels } = searchStore;
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>
);
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>
);
return <div class="stopSearchMenu">
<SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} />
<Body />
<Footer />
</SearchProvider>
</div>;
};

View File

@@ -1,116 +1,116 @@
export enum TrafficStatus {
UNKNOWN = 0,
FLUID,
DISRUPTED,
VERY_DISRUPTED,
BYPASSED
UNKNOWN = 0,
FLUID,
DISRUPTED,
VERY_DISRUPTED,
BYPASSED
}
export class Passage {
line: number;
operator: number;
destinations: string[];
atStop: boolean;
aimedArrivalTs: number;
expectedArrivalTs: number;
arrivalPlatformName: string;
aimedDepartTs: number;
expectedDepartTs: number;
arrivalStatus: string;
departStatus: string;
line: number;
operator: number;
destinations: string[];
atStop: boolean;
aimedArrivalTs: number;
expectedArrivalTs: number;
arrivalPlatformName: string;
aimedDepartTs: number;
expectedDepartTs: number;
arrivalStatus: string;
departStatus: string;
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;
this.operator = operator;
this.destinations = destinations;
this.atStop = atStop;
this.aimedArrivalTs = aimedArrivalTs;
this.expectedArrivalTs = expectedArrivalTs;
this.arrivalPlatformName = arrivalPlatformName;
this.aimedDepartTs = aimedDepartTs;
this.expectedDepartTs = expectedDepartTs;
this.arrivalStatus = arrivalStatus;
this.departStatus = departStatus;
}
};
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;
this.operator = operator;
this.destinations = destinations;
this.atStop = atStop;
this.aimedArrivalTs = aimedArrivalTs;
this.expectedArrivalTs = expectedArrivalTs;
this.arrivalPlatformName = arrivalPlatformName;
this.aimedDepartTs = aimedDepartTs;
this.expectedDepartTs = expectedDepartTs;
this.arrivalStatus = arrivalStatus;
this.departStatus = departStatus;
}
}
export type Passages = Record<string, Record<string, Passage[]>>;
export class Stop {
id: number;
name: string;
town: string;
epsg3857_x: number;
epsg3857_y: number;
stops: Stop[];
lines: number[];
id: number;
name: string;
town: string;
epsg3857_x: number;
epsg3857_y: number;
stops: Stop[];
lines: number[];
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;
this.epsg3857_x = epsg3857_x;
this.epsg3857_y = epsg3857_y;
this.stops = stops;
this.lines = lines;
for (const stop of this.stops) {
this.lines.push(...stop.lines);
}
}
};
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;
this.epsg3857_x = epsg3857_x;
this.epsg3857_y = epsg3857_y;
this.stops = stops;
this.lines = lines;
for (const stop of this.stops) {
this.lines.push(...stop.lines);
}
}
}
export type Stops = Record<number, Stop>;
export type Points = [number, number][];
export class StopShape {
stop_id: number;
type_: number;
epsg3857_bbox: number[];
epsg3857_points: Points;
stop_id: number;
type_: number;
epsg3857_bbox: number[];
epsg3857_points: Points;
constructor(stop_id: number, type_: number, epsg3857_bbox: number[], epsg3857_points: Points) {
this.stop_id = stop_id;
this.type_ = type_;
this.epsg3857_bbox = epsg3857_bbox;
this.epsg3857_points = epsg3857_points;
}
};
constructor(stop_id: number, type_: number, epsg3857_bbox: number[], epsg3857_points: Points) {
this.stop_id = stop_id;
this.type_ = type_;
this.epsg3857_bbox = epsg3857_bbox;
this.epsg3857_points = epsg3857_points;
}
}
export type StopShapes = Record<number, StopShape>;
export class Line {
id: number;
shortName: string;
name: string;
status: string; // TODO: Use an enum
transportMode: string; // TODO: Use an enum
backColorHexa: string;
foreColorHexa: string;
operatorId: number;
accessibility: boolean;
visualSignsAvailable: string; // TODO: Use an enum
audibleSignsAvailable: string; // TODO: Use an enum
stopIds: number[];
id: number;
shortName: string;
name: string;
status: string; // TODO: Use an enum
transportMode: string; // TODO: Use an enum
backColorHexa: string;
foreColorHexa: string;
operatorId: number;
accessibility: boolean;
visualSignsAvailable: string; // TODO: Use an enum
audibleSignsAvailable: string; // TODO: Use an enum
stopIds: number[];
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;
this.shortName = shortName;
this.name = name;
this.status = status;
this.transportMode = transportMode;
this.backColorHexa = backColorHexa;
this.foreColorHexa = foreColorHexa;
this.operatorId = operatorId;
this.accessibility = accessibility;
this.visualSignsAvailable = visualSignsAvailable;
this.audibleSignsAvailable = audibleSignsAvailable;
this.stopIds = stopIds;
}
};
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;
this.shortName = shortName;
this.name = name;
this.status = status;
this.transportMode = transportMode;
this.backColorHexa = backColorHexa;
this.foreColorHexa = foreColorHexa;
this.operatorId = operatorId;
this.accessibility = accessibility;
this.visualSignsAvailable = visualSignsAvailable;
this.audibleSignsAvailable = audibleSignsAvailable;
this.stopIds = stopIds;
}
}
export type Lines = Record<string, Line>;

View File

@@ -5,162 +5,156 @@ import { Line } from './types';
// Thanks to https://dev.to/ycmjason/how-to-create-range-in-javascript-539i
export function* range(start: number, end: number): Generator<number> {
for (let i = start; i <= end; i++) {
yield i;
}
for (let i = start; i <= end; i++) {
yield i;
}
}
const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"];
export const TransportModeWeights: Record<string, number> = {
bus: 1,
tram: 2,
val: 3,
funicular: 4,
metro: 5,
rer: 6,
transilien: 7,
ter: 8,
bus: 1,
tram: 2,
val: 3,
funicular: 4,
metro: 5,
rer: 6,
transilien: 7,
ter: 8,
};
export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined {
let ret = undefined;
if (validTransportModes.includes(mode)) {
return `/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
}
return ret;
let ret = undefined;
if (validTransportModes.includes(mode)) {
return `/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
}
return ret;
}
export function renderLineTransportMode(line: Line): JSX.Element {
return <img src={getTransportModeSrc(line.transportMode)} />
return <img src={getTransportModeSrc(line.transportMode)} />
}
function renderBusLinePicto(line: Line): JSX.Element {
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%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="7.4"
style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
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%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="7.4"
style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>;
}
function renderTramLinePicto(line: Line): JSX.Element {
const lineStyle = { fill: `#${line.backColorHexa}` };
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%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11"
style={{ fill: "#00000" }}>
{line.shortName}
</text>
</svg>
</div>
);
const lineStyle = { fill: `#${line.backColorHexa}` };
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%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11"
style={{ fill: "#00000" }}>
{line.shortName}
</text>
</svg>
</div>;
}
function renderMetroLinePicto(line: Line): JSX.Element {
return (
<div class="metroLinePicto">
<svg viewBox="0 0 20 20">
<circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11" style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
return <div class="metroLinePicto">
<svg viewBox="0 0 20 20">
<circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} />
<text
x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11" style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>;
}
function renderTrainLinePicto(line: Line): JSX.Element {
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%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11"
style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
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%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11"
style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>;
}
export function renderLinePicto(line: Line): JSX.Element {
switch (line.transportMode) {
case "bus":
case "funicular":
return renderBusLinePicto(line);
case "tram":
return renderTramLinePicto(line);
/* case "val": */
case "metro":
return renderMetroLinePicto(line);
case "transilien":
case "rer":
case "ter":
return renderTrainLinePicto(line);
}
switch (line.transportMode) {
case "bus":
case "funicular":
return renderBusLinePicto(line);
case "tram":
return renderTramLinePicto(line);
/* case "val": */
case "metro":
return renderMetroLinePicto(line);
case "transilien":
case "rer":
case "ter":
return renderTrainLinePicto(line);
}
}
export type PositionedPanel = {
position: number;
// TODO: Should be PassagesPanelComponent ?
panel: JSX.Element;
position: number;
// TODO: Should be PassagesPanelComponent ?
panel: JSX.Element;
};
export const ScrollingText: VoidComponent<{ height: number, width: number, content: string }> = (props) => {
let viewBoxRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined;
let viewBoxRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined;
onMount(() => {
if (viewBoxRef !== undefined && textRef !== undefined) {
const overlap = textRef.getComputedTextLength() - viewBoxRef.viewBox.baseVal.width;
if (overlap > 0) {
timeline(
[
[textRef, { x: [-overlap] }, { duration: 5 }],
[textRef, { x: [0] }, { duration: 2 }],
],
{ repeat: Infinity },
);
}
}
});
onMount(() => {
if (viewBoxRef !== undefined && textRef !== undefined) {
const overlap = textRef.getComputedTextLength() - viewBoxRef.viewBox.baseVal.width;
if (overlap > 0) {
timeline(
[
[textRef, { x: [-overlap] }, { duration: 5 }],
[textRef, { x: [0] }, { duration: 2 }],
],
{ repeat: Infinity },
);
}
}
});
return (
<svg ref={viewBoxRef} viewBox={`0 0 ${props.width} ${props.height}`}>
<text
ref={textRef}
x="0%" y="55%"
dominant-baseline="middle"
font-size={`${props.height}px`}>
{props.content}
</text>
</svg >
);
return <svg ref={viewBoxRef} viewBox={`0 0 ${props.width} ${props.height}`}>
<text
ref={textRef}
x="0%" y="55%"
dominant-baseline="middle"
font-size={`${props.height}px`}>
{props.content}
</text>
</svg>;
}

View File

@@ -1,22 +1,23 @@
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"noImplicitAny": true,
"target": "ES6",
"moduleResolution": "node",
"allowJs": true,
"outDir": "build",
"strict": true,
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true,
"plugins": [
{
"name": "typescript-eslint-language-service"
}
],
"lib": ["ES2021", "DOM"],
},
"include": ["src"]
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"noImplicitAny": true,
"target": "ES6",
"module": "esnext",
"moduleResolution": "node",
"allowJs": true,
"outDir": "build",
"strict": true,
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true,
"plugins": [
{
"name": "typescript-eslint-language-service"
}
],
"lib": ["ES2021", "DOM"],
},
"include": ["src"]
}