8 Commits

28 changed files with 1941 additions and 2017 deletions

View File

@@ -1,10 +1,7 @@
from asyncio import sleep from asyncio import sleep
from logging import getLogger from logging import getLogger
from typing import Annotated, AsyncIterator
from fastapi import Depends
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from sqlalchemy import text
from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
async_sessionmaker, async_sessionmaker,
@@ -16,6 +13,7 @@ from sqlalchemy.ext.asyncio import (
from .base_class import Base from .base_class import Base
from ..settings import DatabaseSettings from ..settings import DatabaseSettings
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -30,8 +28,7 @@ class Database:
except (SQLAlchemyError, AttributeError) as e: except (SQLAlchemyError, AttributeError) as e:
logger.exception(e) logger.exception(e)
raise
return None
# TODO: Preserve UserLastStopSearchResults table from drop. # TODO: Preserve UserLastStopSearchResults table from drop.
async def connect( async def connect(

View File

@@ -60,7 +60,7 @@ types-aiofiles = "^22.1.0.2"
wrapt = "^1.14.1" wrapt = "^1.14.1"
pydocstyle = "^6.2.2" pydocstyle = "^6.2.2"
dill = "^0.3.6" dill = "^0.3.6"
python-lsp-ruff = "^1.0.5" python-lsp-ruff = "^2.1.0"
python-lsp-server = "^1.7.1" python-lsp-server = "^1.7.1"
autopep8 = "^2.0.1" autopep8 = "^2.0.1"
pyflakes = "^3.0.1" pyflakes = "^3.0.1"

View File

@@ -30,8 +30,9 @@
"@stitches/core": "^1.2.8", "@stitches/core": "^1.2.8",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"matrix-widget-api": "^1.1.1", "matrix-widget-api": "^1.1.1",
"ol": "^7.3.0", "ol": "^8.2.0",
"solid-js": "^1.6.6", "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 { .App {
--idfm-black: #2c2e35; --idfm-black: #2c2e35;
--idfm-white: #ffffff; --idfm-white: #ffffff;
--neutral-color: #d7dbdf; --neutral-color: #d7dbdf;
--border-radius: calc(15/1920*100%); --border-radius: calc(15/1920*100%);
height: inherit; height: inherit;
width: inherit; width: inherit;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
overflow-x: scroll; overflow-x: scroll;
display: flex; display: flex;
text-align: center; text-align: center;
.panel { .panel {
min-width: 100%; min-width: 100%;
height: inherit; height: inherit;
width: 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 { Component, createSignal, onCleanup, onMount } from 'solid-js';
import { IVisibilityActionRequest, MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction } from 'matrix-widget-api'; // import { IVisibilityActionRequest, MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction } from 'matrix-widget-api';
import { HopeProvider } from "@hope-ui/solid";
import { BusinessDataProvider } from './businessData'; import { BusinessDataProvider } from './businessData';
import { AppContextProvider } from './appContext'; import { AppContextProvider } from './appContext';
import { PassagesDisplay } from './passagesDisplay'; import { PassagesDisplay } from './passagesDisplay';
import { StopsSearchMenu } from './stopsSearchMenu/stopsSearchMenu'; import { StopsSearchMenu } from './stopsSearchMenu/stopsSearchMenu';
import "./App.scss"; import "./App.scss";
import { onCleanup, onMount } from 'solid-js';
function parseFragment() { function parseFragment() {
const fragmentString = (window.location.hash || "?"); const fragmentString = (window.location.hash || "?");
return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0))); return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0)));
} }
const App: Component = () => { const App: Component = () => {
console.log('App: New'); console.log('App: New');
const qs = parseFragment(); const qs = parseFragment();
const widgetId = qs.get('widgetId'); const widgetId = qs.get('widgetId');
const userId = qs.get('userId'); const userId = qs.get('userId');
console.log("App: widgetId:" + widgetId); console.log("App: widgetId:" + widgetId);
console.log("App: userId:" + userId); console.log("App: userId:" + userId);
const api = new WidgetApi(widgetId != null ? widgetId : undefined); // const api = new WidgetApi(widgetId != null ? widgetId : undefined);
api.requestCapability(MatrixCapabilities.AlwaysOnScreen); // api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
api.start(); // api.start();
api.on("ready", function() { // api.on("ready", function() {
console.log("App: widget API is READY !!!!"); // console.log("App: widget API is READY !!!!");
}); // });
// Seems to don´t be used... // Seems to don´t be used...
api.on(`action:${WidgetApiToWidgetAction.UpdateVisibility}`, (ev: CustomEvent<IVisibilityActionRequest>) => { // api.on(`action:${WidgetApiToWidgetAction.UpdateVisibility}`, (ev: CustomEvent<IVisibilityActionRequest>) => {
console.log("App: Visibility change"); // console.log("App: Visibility change");
ev.preventDefault(); // we're handling it, so stop the widget API from doing something. // ev.preventDefault(); // we're handling it, so stop the widget API from doing something.
console.log("App: ", ev.detail); // custom handling here // console.log("App: ", ev.detail); // custom handling here
/* api.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); */ // /* api.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); */
api.transport.reply(ev.detail, {}); // api.transport.reply(ev.detail, {});
}); // });
createSignal({ createSignal({
height: window.innerHeight, height: window.innerHeight,
width: window.innerWidth width: window.innerWidth
}); });
const onResize = () => { const onResize = () => {
const body = document.body; const body = document.body;
if (window.innerWidth * 9 / 16 < window.innerHeight) { if (window.innerWidth * 9 / 16 < window.innerHeight) {
body.style['height'] = 'auto'; body.style['height'] = 'auto';
body.style['width'] = '100vw'; body.style['width'] = '100vw';
} }
else { else {
body.style['height'] = '100vh'; body.style['height'] = '100vh';
body.style['width'] = 'auto'; body.style['width'] = 'auto';
} }
}; };
onMount(() => { onMount(() => {
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
onResize(); onResize();
}); });
onCleanup(() => { onCleanup(() => {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
}) })
return ( return <BusinessDataProvider>
<BusinessDataProvider> <AppContextProvider>
<AppContextProvider> <div class="App">
<HopeProvider> <div class="panel">
<div class="App"> <StopsSearchMenu />
<div class="panel"> </div>
<StopsSearchMenu /> <div class="panel">
</div> <PassagesDisplay />
<div class="panel"> </div>
<PassagesDisplay /> </div>
</div> </AppContextProvider>
</div> </BusinessDataProvider>;
</HopeProvider>
</AppContextProvider>
</BusinessDataProvider>
);
}; };
export default App; export default App;

View File

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

View File

@@ -1,31 +1,31 @@
%transportMode { %transportMode {
aspect-ratio : 1 / 1; aspect-ratio : 1 / 1;
} }
%linePicto { %linePicto {
font-family: IDFVoyageur-bold; font-family: IDFVoyageur-bold;
} }
%tramLinePicto { %tramLinePicto {
@extend %linePicto; @extend %linePicto;
aspect-ratio : 1 / 1; aspect-ratio : 1 / 1;
} }
%trainLinePicto { %trainLinePicto {
@extend %linePicto; @extend %linePicto;
aspect-ratio : 1 / 1; aspect-ratio : 1 / 1;
} }
%metroLinePicto { %metroLinePicto {
@extend %linePicto; @extend %linePicto;
aspect-ratio : 1 / 1; aspect-ratio : 1 / 1;
} }
%busLinePicto { %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'; import { Stop } from './types';
export interface AppContextStore { export interface AppContextStore {
getDisplayedStops: () => Stop[]; getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void; setDisplayedStops: (stops: Stop[]) => void;
}; }
export const AppContextContext = createContext<AppContextStore>(); export const AppContextContext = createContext<AppContextStore>();
export function AppContextProvider(props: { children: JSX.Element }) { export function AppContextProvider(props: { children: JSX.Element }) {
type Store = { type Store = {
displayedStops: Stop[]; displayedStops: Stop[];
}; };
const [store, setStore] = createStore<Store>({ const [store, setStore] = createStore<Store>({
displayedStops: [], displayedStops: [],
}); });
const getDisplayedStops = (): Stop[] => { const getDisplayedStops = (): Stop[] => {
return store.displayedStops; return store.displayedStops;
} }
const setDisplayedStops = (stops: Stop[]): void => { const setDisplayedStops = (stops: Stop[]): void => {
console.log("setDisplayedStops=", stops); console.log("setDisplayedStops=", stops);
// setStore((s: Store) => { setStore('displayedStops', stops);
setStore('displayedStops', stops); }
// return s;
// });
}
return ( return (
<AppContextContext.Provider value={{ <AppContextContext.Provider value={{
getDisplayedStops, setDisplayedStops, getDisplayedStops, setDisplayedStops,
}}> }}>
{props.children} {props.children}
</AppContextContext.Provider> </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 type StopDestinations = Record<string, string[]>;
export interface BusinessDataStore { export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>; getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>; getLinePassages: (lineId: string) => Record<string, Passage[]>;
getLineDestinations: (lineId: string) => string[]; getLineDestinations: (lineId: string) => string[];
getDestinationPassages: (lineId: string, destination: string) => Passage[]; getDestinationPassages: (lineId: string, destination: string) => Passage[];
passages: () => Passages; passages: () => Passages;
getPassagesLineIds: () => string[]; getPassagesLineIds: () => string[];
refreshPassages: (stopId: number) => Promise<void>; refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void; addPassages: (passages: Passages) => void;
clearPassages: () => void; clearPassages: () => void;
getStop: (stopId: number) => Stop | undefined; getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>; searchStopByName: (name: string) => Promise<Stops>;
getStopDestinations: (stopId: number) => Promise<StopDestinations | undefined>; getStopDestinations: (stopId: number) => Promise<StopDestinations | undefined>;
getStopShape: (stopId: number) => Promise<StopShape | undefined>; getStopShape: (stopId: number) => Promise<StopShape | undefined>;
}; }
export const BusinessDataContext = createContext<BusinessDataStore>(); export const BusinessDataContext = createContext<BusinessDataStore>();
export function BusinessDataProvider(props: { children: JSX.Element }) { 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 = { type Store = {
lines: Lines; lines: Lines;
passages: Passages; passages: Passages;
stops: Stops; stops: Stops;
stopShapes: StopShapes; 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> => { const getLine = async (lineId: string): Promise<Line> => {
let line = store.lines[lineId]; let line = store.lines[lineId];
if (line === undefined) { if (line === undefined) {
console.log(`${lineId} not found... fetch it from backend.`); console.log(`${lineId} not found... fetch it from backend.`);
const response = await fetch(`${serverUrl()}/line/${lineId}`, { const response = await fetch(`${serverUrl()}/line/${lineId}`, {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
const json = await response.json(); const json = await response.json();
if (response.ok) { if (response.ok) {
setStore('lines', lineId, json); setStore('lines', lineId, json);
line = json; line = json;
} }
else { else {
console.warn(`No line found for ${lineId} line id:`, json); console.warn(`No line found for ${lineId} line id:`, json);
} }
} }
return line; return line;
} }
const getLinePassages = (lineId: string): Record<string, Passage[]> => { const getLinePassages = (lineId: string): Record<string, Passage[]> => {
return store.passages[lineId]; return store.passages[lineId];
} }
const getLineDestinations = (lineId: string): string[] => { const getLineDestinations = (lineId: string): string[] => {
return Object.keys(store.passages[lineId]); return Object.keys(store.passages[lineId]);
} }
// TODO: Remove this method: it's based on the next passages and return nothing until the refreshPassages is called. // TODO: Remove this method: it's based on the next passages and return nothing until the refreshPassages is called.
const getDestinationPassages = (lineId: string, destination: string): Passage[] => { const getDestinationPassages = (lineId: string, destination: string): Passage[] => {
return store.passages[lineId][destination]; return store.passages[lineId][destination];
} }
const passages = (): Passages => { const passages = (): Passages => {
return store.passages; return store.passages;
} }
const getPassagesLineIds = (): string[] => { const getPassagesLineIds = (): string[] => {
return Object.keys(store.passages); return Object.keys(store.passages);
} }
const _cleanupPassages = (passages: Passages): void => { const _cleanupPassages = (passages: Passages): void => {
const deadline = Math.floor(Date.now() / 1000) - 60; const deadline = Math.floor(Date.now() / 1000) - 60;
for (const linePassages of Object.values(passages)) { for (const linePassages of Object.values(passages)) {
for (const destination of Object.keys(linePassages)) { for (const destination of Object.keys(linePassages)) {
const destinationPassages = linePassages[destination]; const destinationPassages = linePassages[destination];
const cleaned = []; const cleaned = [];
for (const passage of destinationPassages) { for (const passage of destinationPassages) {
if (passage.expectedDepartTs > deadline) { if (passage.expectedDepartTs > deadline) {
cleaned.push(passage); cleaned.push(passage);
} }
} }
linePassages[destination] = cleaned; linePassages[destination] = cleaned;
} }
} }
} }
const refreshPassages = async (stopId: number): Promise<void> => { const refreshPassages = async (stopId: number): Promise<void> => {
console.log(`Fetching data for ${stopId}`); console.log(`Fetching data for ${stopId}`);
const httpOptions = { headers: { "Content-Type": "application/json" } }; const httpOptions = { headers: { "Content-Type": "application/json" } };
const response = await fetch(`${serverUrl()}/stop/${stopId}/nextPassages`, httpOptions); const response = await fetch(`${serverUrl()}/stop/${stopId}/nextPassages`, httpOptions);
const json = await response.json(); const json = await response.json();
if (response.ok) { if (response.ok) {
_cleanupPassages(json.passages); _cleanupPassages(json.passages);
addPassages(json.passages); addPassages(json.passages);
} }
else { else {
console.warn(`No passage found for ${stopId} stop:`, json); console.warn(`No passage found for ${stopId} stop:`, json);
} }
} }
const addPassages = (passages: Passages): void => { const addPassages = (passages: Passages): void => {
batch(() => { batch(() => {
const storePassages = store.passages; const storePassages = store.passages;
for (const lineId of Object.keys(passages)) { for (const lineId of Object.keys(passages)) {
const newLinePassages = passages[lineId]; const newLinePassages = passages[lineId];
const linePassages = storePassages[lineId]; const linePassages = storePassages[lineId];
if (linePassages === undefined) { if (linePassages === undefined || Object.keys(linePassages).length == 0) {
setStore('passages', lineId, newLinePassages); setStore('passages', lineId, newLinePassages);
} }
else { else {
for (const destination of Object.keys(newLinePassages)) { for (const destination of Object.keys(newLinePassages)) {
const newLinePassagesDestination = newLinePassages[destination]; const newLinePassagesDestination = newLinePassages[destination];
const linePassagesDestination = linePassages[destination]; const linePassagesDestination = linePassages[destination];
if (linePassagesDestination === undefined) { if (linePassagesDestination === undefined) {
setStore('passages', lineId, destination, newLinePassagesDestination); setStore('passages', lineId, destination, newLinePassagesDestination);
} }
else { else {
if (linePassagesDestination.length - newLinePassagesDestination.length != 0) { if (linePassagesDestination.length - newLinePassagesDestination.length != 0) {
console.log(`Server provides ${newLinePassagesDestination.length} passages, \ console.log(`Server provides ${newLinePassagesDestination.length} passages, \
${linePassagesDestination.length} here... refresh all them.`); ${linePassagesDestination.length} here... refresh all them.`);
setStore('passages', lineId, destination, newLinePassagesDestination); setStore('passages', lineId, destination, newLinePassagesDestination);
} }
else { else {
linePassagesDestination.forEach((passage, index) => { linePassagesDestination.forEach((passage, index) => {
const newPassage = newLinePassagesDestination[index]; const newPassage = newLinePassagesDestination[index];
if (passage.expectedDepartTs != newPassage.expectedDepartTs) { if (passage.expectedDepartTs != newPassage.expectedDepartTs) {
console.log(`Refresh expectedDepartTs (${passage.expectedDepartTs} -> ${newPassage.expectedDepartTs}`); console.log(`Refresh expectedDepartTs (${passage.expectedDepartTs} -> ${newPassage.expectedDepartTs}`);
setStore('passages', lineId, destination, index, 'expectedDepartTs', newPassage.expectedDepartTs); setStore('passages', lineId, destination, index, 'expectedDepartTs', newPassage.expectedDepartTs);
} }
if (passage.expectedArrivalTs != newPassage.expectedArrivalTs) { if (passage.expectedArrivalTs != newPassage.expectedArrivalTs) {
console.log(`Refresh expectedArrivalTs (${passage.expectedArrivalTs} -> ${newPassage.expectedArrivalTs}`); console.log(`Refresh expectedArrivalTs (${passage.expectedArrivalTs} -> ${newPassage.expectedArrivalTs}`);
setStore('passages', lineId, destination, index, 'expectedArrivalTs', newPassage.expectedArrivalTs); setStore('passages', lineId, destination, index, 'expectedArrivalTs', newPassage.expectedArrivalTs);
} }
}); });
} }
} }
} }
} }
} }
}); });
} }
const clearPassages = (): void => { const clearPassages = (): void => {
setStore((s: Store): Store => { setStore((s: Store): Store => {
for (const lineId of Object.keys(s.passages)) { for (const lineId of Object.keys(s.passages)) {
setStore('passages', lineId, undefined); setStore('passages', lineId, {});
} }
return s; return s;
}); });
} }
const getStop = (stopId: number): Stop | undefined => { const getStop = (stopId: number): Stop | undefined => {
return store.stops[stopId]; return store.stops[stopId];
} }
const searchStopByName = async (name: string): Promise<Stops> => { const searchStopByName = async (name: string): Promise<Stops> => {
const byIdStops: Stops = {}; const byIdStops: Stops = {};
const response = await fetch(`${serverUrl()}/stop/?name=${name}`, { const response = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
const json = await response.json(); const json = await response.json();
if (response.ok) { if (response.ok) {
for (const stop of json) { for (const stop of json) {
byIdStops[stop.id] = stop; byIdStops[stop.id] = stop;
setStore('stops', stop.id, stop); setStore('stops', stop.id, stop);
if (stop.stops !== undefined) { if (stop.stops !== undefined) {
for (const innerStop of stop.stops) { for (const innerStop of stop.stops) {
setStore('stops', innerStop.id, innerStop); setStore('stops', innerStop.id, innerStop);
} }
} }
} }
} }
else { else {
console.warn(`No stop found for '${name}' query:`, json); console.warn(`No stop found for '${name}' query:`, json);
} }
return byIdStops; return byIdStops;
} }
const getStopDestinations = async (stopId: number): Promise<StopDestinations | undefined> => { const getStopDestinations = async (stopId: number): Promise<StopDestinations | undefined> => {
const response = await fetch(`${serverUrl()}/stop/${stopId}/destinations`, { const response = await fetch(`${serverUrl()}/stop/${stopId}/destinations`, {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
const destinations = response.ok ? await response.json() : undefined; const destinations = response.ok ? await response.json() : undefined;
return destinations; return destinations;
} }
const getStopShape = async (stopId: number): Promise<StopShape | undefined> => { const getStopShape = async (stopId: number): Promise<StopShape | undefined> => {
let shape = store.stopShapes[stopId]; let shape = store.stopShapes[stopId];
if (shape === undefined) { if (shape === undefined) {
console.log(`No shape found for ${stopId} stop... fetch it from backend.`); console.log(`No shape found for ${stopId} stop... fetch it from backend.`);
const response = await fetch(`${serverUrl()}/stop/${stopId}/shape`, { const response = await fetch(`${serverUrl()}/stop/${stopId}/shape`, {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
const json = await response.json(); const json = await response.json();
if (response.ok) { if (response.ok) {
setStore('stopShapes', stopId, json); setStore('stopShapes', stopId, json);
shape = json; shape = json;
} }
else { else {
console.warn(`No shape found for ${stopId} stop:`, json); console.warn(`No shape found for ${stopId} stop:`, json);
} }
} }
return shape; return shape;
} }
return ( return (
<BusinessDataContext.Provider value={{ <BusinessDataContext.Provider value={{
getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds, getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds,
refreshPassages, addPassages, clearPassages, refreshPassages, addPassages, clearPassages,
getStop, getStopDestinations, getStopShape, searchStopByName getStop, getStopDestinations, getStopShape, searchStopByName
}}>
{props.children} }}>
</BusinessDataContext.Provider> {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 // Inspired by https://github.com/hope-ui/hope-ui/blob/main/apps/docs/src/icons/IconHamburgerMenu.tsx
export const IconHamburgerMenu: VoidComponent<{}> = () => { export const IconHamburgerMenu: VoidComponent<{}> = () => {
return ( return (
<svg class="iconHamburgerMenu" viewBox="0 0 15 15"> <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 <path
13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5 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
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 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
13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z" 8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761
fill="currentColor" 13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z"
fill-rule="evenodd" fill="currentColor"
clip-rule="evenodd" fill-rule="evenodd"
/> clip-rule="evenodd"
</svg>); />
</svg>);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,169 +12,158 @@ import "./passagesPanel.scss";
const UnavailablePassage: VoidComponent<{ style: string }> = (props) => { const UnavailablePassage: VoidComponent<{ style: string }> = (props) => {
const textStyle = { fill: "#000000" }; const textStyle = { fill: "#000000" };
return ( return <div class={props.style}>
<div class={props.style}> <svg viewBox="0 0 230 110">
<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="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="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>
<text x="100%" y="100" font-size="25" text-anchor="end" style={textStyle}>disponible</text> </svg>
</svg> </div>;
</div>
);
} }
const Platform: VoidComponent<{ name: string }> = (props) => { const Platform: VoidComponent<{ name: string }> = (props) => {
const platformTextPaddingPx: number = 20; const platformTextPaddingPx: number = 20;
const viewBoxWidthPx: number = 215; const viewBoxWidthPx: number = 215;
let rectRef: SVGSVGElement | undefined = undefined; let rectRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined; let textRef: SVGTextElement | undefined = undefined;
onMount(() => { onMount(() => {
if (rectRef !== undefined && textRef !== undefined) { if (rectRef !== undefined && textRef !== undefined) {
const textWidth = textRef.getComputedTextLength(); const textWidth = textRef.getComputedTextLength();
const rectWidth = textWidth + platformTextPaddingPx * 2; const rectWidth = textWidth + platformTextPaddingPx * 2;
rectRef.setAttribute("width", `${rectWidth}px`); rectRef.setAttribute("width", `${rectWidth}px`);
rectRef.setAttribute("x", `${viewBoxWidthPx - rectWidth}px`); rectRef.setAttribute("x", `${viewBoxWidthPx - rectWidth}px`);
textRef.setAttribute("x", `${viewBoxWidthPx - platformTextPaddingPx}px`); textRef.setAttribute("x", `${viewBoxWidthPx - platformTextPaddingPx}px`);
} }
}); });
return ( return <svg class="platform" viewBox={`0 0 ${viewBoxWidthPx} 40`}>
<svg class="platform" viewBox={`0 0 ${viewBoxWidthPx} 40`}> <rect ref={rectRef} x="0" y="0" height="100%" rx="9" ry="9" />
<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" }}>
<text ref={textRef} x="100%" y="55%" dominant-baseline="middle" text-anchor="end" font-size="25" style={{ fill: "#ffffff" }}> QUAI {props.name}
QUAI {props.name} </text>
</text> </svg>;
</svg>
);
} }
const TtwPassage: VoidComponent<{ const TtwPassage: VoidComponent<{
line: Line, destination: string, index: number, style: string, line: Line, destination: string, index: number, style: string,
withPlatformStyle: string, fontSize: number, fallbackStyle: string withPlatformStyle: string, fontSize: number, fallbackStyle: string
}> = (props) => { }> = (props) => {
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined) if (businessDataContext === undefined)
return <div />; 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 (() => { return (() => {
const passage = getDestinationPassages(props.line.id, props.destination)[props.index]; const passage = getDestinationPassages(props.line.id, props.destination)[props.index];
const refTs = passage !== undefined ? (passage.expectedDepartTs !== null ? passage.expectedDepartTs : passage.expectedArrivalTs) : 0; const refTs = passage !== undefined ? (passage.expectedDepartTs !== null ? passage.expectedDepartTs : passage.expectedArrivalTs) : 0;
const ttwSec = refTs - (getTime(dateNow()) / 1000); 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 ttwRepr = ttwSec < 3600 ? `${Math.floor(ttwSec / 60).toString().padStart(2, "0")} min` : format(refTs * 1000, "HH:mm");
const isApproaching = ttwSec <= 60; const isApproaching = ttwSec <= 60;
const text = <svg class="passage" viewBox={`0 0 215 ${props.fontSize}`}> const text = <svg class="passage" viewBox={`0 0 215 ${props.fontSize}`}>
<Motion.text <Motion.text
x="100%" y="55%" x="100%" y="55%"
dominant-baseline="middle" text-anchor="end" dominant-baseline="middle" text-anchor="end"
font-size={props.fontSize} style={{ fill: "#000000" }} font-size={props.fontSize} style={{ fill: "#000000" }}
initial={isApproaching ? undefined : false} initial={isApproaching ? undefined : false}
animate={{ opacity: [1, 0, 1] }} animate={{ opacity: [1, 0, 1] }}
transition={transition}> transition={transition}>
{ttwRepr} {ttwRepr}
</Motion.text> </Motion.text>
</svg>; </svg>;
return ( return <Show when={passage !== undefined} fallback={<UnavailablePassage style={props.fallbackStyle} />}>
<Show when={passage !== undefined} fallback=<UnavailablePassage style={props.fallbackStyle} />> <Show
<Show when={passage.arrivalPlatformName !== null}
when={passage.arrivalPlatformName !== null} fallback={
fallback={ <div class={props.style}>
<div class={props.style}> {text}
{text} </div>}>
</div>}> <div class={props.withPlatformStyle}>
<div class={props.withPlatformStyle}> {text}
{text} <Platform name={passage.arrivalPlatformName} />
<Platform name={passage.arrivalPlatformName} /> </div>
</div> </Show>
</Show > </Show>;
</Show > });
);
});
} }
/* TODO: Manage end of service */ /* TODO: Manage end of service */
const DestinationPassages: VoidComponent<{ line: Line, destination: string }> = (props) => { const DestinationPassages: VoidComponent<{ line: Line, destination: string }> = (props) => {
/* TODO: Find where to get data to compute traffic status. */ /* TODO: Find where to get data to compute traffic status. */
const trafficStatusColor = new Map<TrafficStatus, string>([ const trafficStatusColor = new Map<TrafficStatus, string>([
[TrafficStatus.UNKNOWN, "#ffffff"], [TrafficStatus.UNKNOWN, "#ffffff"],
[TrafficStatus.FLUID, "#00643c"], [TrafficStatus.FLUID, "#00643c"],
[TrafficStatus.DISRUPTED, "#ffbe00"], [TrafficStatus.DISRUPTED, "#ffbe00"],
[TrafficStatus.VERY_DISRUPTED, "#ff5a00"], [TrafficStatus.VERY_DISRUPTED, "#ff5a00"],
[TrafficStatus.BYPASSED, "#ffffff"] [TrafficStatus.BYPASSED, "#ffffff"]
]); ]);
// TODO: Manage traffic status // TODO: Manage traffic status
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) }; // const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) }; const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
return ( return <div class="line">
<div class="line"> <div class="transportMode">
<div class="transportMode"> {renderLineTransportMode(props.line)}
{renderLineTransportMode(props.line)} </div>
</div> {renderLinePicto(props.line)}
{renderLinePicto(props.line)} <div class="destination">
<div class="destination"> <ScrollingText height={40} width={600} content={props.destination} />
<ScrollingText height={40} width={600} content={props.destination} /> </div>
</div> <div class="trafficStatus">
<div class="trafficStatus"> <svg viewBox="0 0 51 51">
<svg viewBox="0 0 51 51"> <circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} />
<circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} /> </svg>
</svg> </div>
</div> <TtwPassage line={props.line} destination={props.destination} index={0}
<TtwPassage line={props.line} destination={props.destination} index={0} style="firstPassage" withPlatformStyle="withPlatformFirstPassage"
style="firstPassage" withPlatformStyle="withPlatformFirstPassage" fontSize={50} fallbackStyle="unavailableFirstPassage" />
fontSize={50} fallbackStyle="unavailableFirstPassage" /> <TtwPassage line={props.line} destination={props.destination} index={1}
<TtwPassage line={props.line} destination={props.destination} index={1} style="secondPassage" withPlatformStyle="withPlatformSecondPassage"
style="secondPassage" withPlatformStyle="withPlatformSecondPassage" fontSize={45} fallbackStyle="unavailableSecondPassage" />
fontSize={45} fallbackStyle="unavailableSecondPassage" /> </div>;
</div >
);
} }
export type PassagesPanelComponentProps = ParentProps & { stopId: number, lineIds: string[], show: boolean }; export type PassagesPanelComponentProps = ParentProps & { stopId: number, lineIds: string[], show: boolean };
export type PassagesPanelComponent = ParentComponent<PassagesPanelComponentProps>; export type PassagesPanelComponent = ParentComponent<PassagesPanelComponentProps>;
export const PassagesPanel: PassagesPanelComponent = (props) => { export const PassagesPanel: PassagesPanelComponent = (props) => {
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined) if (businessDataContext === undefined)
return <div />; return <div />;
const { getLine, getLineDestinations } = businessDataContext; const { getLine, getLineDestinations } = businessDataContext;
const getLines = async (lineIds: string[]): Promise<Line[]> => { const getLines = async (lineIds: string[]): Promise<Line[]> => {
const lines = await Promise.all<Promise<Line>[]>(lineIds.map((lineId) => getLine(lineId))); const lines = await Promise.all<Promise<Line>[]>(lineIds.map((lineId) => getLine(lineId)));
return lines; return lines;
} }
const [lines] = createResource<Line[], string[]>(props.lineIds, getLines); const [lines] = createResource<Line[], string[]>(props.lineIds, getLines);
return ( return <div classList={{ "passagesContainer": true, "displayed": props.show }} >
<div classList={{ ["passagesContainer"]: true, ["displayed"]: props.show }} > <Show when={lines() !== undefined} >
<Show when={lines() !== undefined} > <For each={lines()}>
<For each={lines()}> {(line) =>
{(line) => <Show when={getLineDestinations(line.id) !== undefined}>
<Show when={getLineDestinations(line.id) !== undefined}> <For each={getLineDestinations(line.id)}>{
<For each={getLineDestinations(line.id)}> (destination) => <DestinationPassages line={line} destination={destination} />
{(destination) => <DestinationPassages line={line} destination={destination} />} }</For>
</For> </Show>
</Show> }</For>
} </Show>
</For> </div>;
</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 { createStore } from 'solid-js/store';
import { Marker as LeafletMarker } from 'leaflet'; import { Marker as LeafletMarker } from 'leaflet';
import { Stop, Stops } from './types'; import { Stop } from './types';
export type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>; export type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>;
export interface SearchStore { export interface SearchStore {
getFoundStops: () => Stop[]; getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void; setFoundStops: (stops: Stop[]) => void;
getDisplayedStops: () => Stop[]; getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void; setDisplayedStops: (stops: Stop[]) => void;
addMarkers: (stopId: number, markers: LeafletMarker[]) => void; addMarkers: (stopId: number, markers: LeafletMarker[]) => void;
}; };
export const SearchContext = createContext<SearchStore>(); export const SearchContext = createContext<SearchStore>();
export function SearchProvider(props: { children: JSX.Element }) { export function SearchProvider(props: { children: JSX.Element }) {
type Store = { type Store = {
foundStops: Stop[]; foundStops: Stop[];
markers: ByStopIdMarkers; markers: ByStopIdMarkers;
displayedStops: Stop[]; displayedStops: Stop[];
}; };
const [store, setStore] = createStore<Store>({ foundStops: [], markers: {}, displayedStops: [] }); const [store, setStore] = createStore<Store>({ foundStops: [], markers: {}, displayedStops: [] });
const getFoundStops = (): Stop[] => { const getFoundStops = (): Stop[] => {
return store.foundStops; return store.foundStops;
} }
const setFoundStops = (stops: Stop[]): void => { const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops); setStore('foundStops', stops);
} }
const getDisplayedStops = (): Stop[] => { const getDisplayedStops = (): Stop[] => {
return store.displayedStops; return store.displayedStops;
} }
const setDisplayedStops = (stops: Stop[]): void => { const setDisplayedStops = (stops: Stop[]): void => {
setStore((s: Store) => { setStore((s: Store) => {
setStore('displayedStops', stops); setStore('displayedStops', stops);
return s; return s;
}); });
} }
const addMarkers = (stopId: number, markers: L.Marker[]): void => { const addMarkers = (stopId: number, markers: L.Marker[]): void => {
setStore('markers', stopId, markers); setStore('markers', stopId, markers);
} }
return ( return <SearchContext.Provider value={{ getFoundStops, setFoundStops, getDisplayedStops, setDisplayedStops, addMarkers }}>
<SearchContext.Provider value={{ getFoundStops, setFoundStops, getDisplayedStops, setDisplayedStops, addMarkers }}> {props.children}
{props.children} </SearchContext.Provider>;
</SearchContext.Provider>
);
} }

View File

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

View File

@@ -24,192 +24,190 @@ import "./map.scss";
export const Map: ParentComponent<{}> = () => { export const Map: ParentComponent<{}> = () => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext); const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined) if (businessDataStore === undefined || searchStore === undefined)
return <div />; return <div />;
const { getStop } = businessDataStore; const { getStop } = businessDataStore;
const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore; const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore;
const [selectedMapStop, setSelectedMapStop] = createSignal<Stop | undefined>(undefined); const [selectedMapStop, setSelectedMapStop] = createSignal<Stop | undefined>(undefined);
const [isPopupDisplayed, setPopupDisplayed] = createSignal<boolean>(false); const [isPopupDisplayed, setPopupDisplayed] = createSignal<boolean>(false);
const mapCenter = [260769.80336542107, 6250587.867330259]; // EPSG:3857 const mapCenter = [260769.80336542107, 6250587.867330259]; // EPSG:3857
const fitDurationMs = 1500; const fitDurationMs = 1500;
const flashDurationMs = 2000; const flashDurationMs = 2000;
// TODO: Set padding according to the marker design. // TODO: Set padding according to the marker design.
const fitPointsPadding = [50, 50, 50, 50]; 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 [mapRef, setMapRef] = createSignal<HTMLDivElement>();
const stopVectorLayer = new OlVectorLayer({ source: stopVectorSource });
let overlay: OlOverlay | undefined = undefined; const stopVectorSource = new OlVectorSource({ features: [] });
let map: OlMap | undefined = undefined; 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 => { const onClickedMap = async (event): Promise<void> => {
overlay = new OlOverlay({ const features = await stopVectorLayer.getFeatures(event.pixel);
element: popup, // Handle only the first feature
autoPan: { if (features.length > 0) {
animation: { await onClickedFeature(features[0]);
duration: 250, }
}, else {
}, setPopupDisplayed(false);
}); setSelectedMapStop(undefined);
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 displayedFeatures: Record<number, OlFeature> = {};
const features = await stopVectorLayer.getFeatures(event.pixel);
// Handle only the first feature
if (features.length > 0) {
await onClickedFeature(features[0]);
}
else {
setPopupDisplayed(false);
setSelectedMapStop(undefined);
}
}
const onClickedFeature = async (feature: OlFeatureLike): Promise<void> => { const overlay = new OlOverlay({
const stopId: number = feature.getId(); element: popup,
const stop = getStop(stopId); autoPan: {
// TODO: Handle StopArea (use center given by the backend) animation: {
if (stop?.epsg3857_x !== undefined && stop?.epsg3857_y !== undefined) { duration: 250,
setSelectedMapStop(stop); },
map?.getView().animate( },
{ });
center: [stop.epsg3857_x, stop.epsg3857_y], const map = new OlMap({
duration: 1000 target: "map",
}, controls: [], // remove controls
// Display the popup once the animation finished view: new OlView({
() => setPopupDisplayed(true) 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 // Filling the map with stops shape
createEffect(() => { createEffect(() => {
const stops = getFoundStops(); const stops = getFoundStops();
const foundStopIds = new Set(); const foundStopIds = new Set();
for (const foundStop of stops) { for (const foundStop of stops) {
foundStopIds.add(foundStop.id); foundStopIds.add(foundStop.id);
if (foundStop.stops !== undefined) { if (foundStop.stops !== undefined) {
foundStop.stops.forEach(s => foundStopIds.add(s.id)); foundStop.stops.forEach(s => foundStopIds.add(s.id));
} }
} }
for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) { for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) {
const stopId = parseInt(stopIdStr); const stopId = parseInt(stopIdStr);
if (!foundStopIds.has(stopId)) { if (!foundStopIds.has(stopId)) {
console.log(`Remove feature for ${stopId}`); console.log(`Remove feature for ${stopId}`);
stopVectorSource.removeFeature(feature); stopVectorSource.removeFeature(feature);
delete displayedFeatures[stopId]; delete displayedFeatures[stopId];
} }
} }
const features = getAllMapFeatures(); const features = getAllMapFeatures();
for (const [stopIdStr, feature] of Object.entries(features)) { for (const [stopIdStr, feature] of Object.entries(features)) {
const stopId = parseInt(stopIdStr); const stopId = parseInt(stopIdStr);
if (foundStopIds.has(stopId) && !(stopId in displayedFeatures)) { if (foundStopIds.has(stopId) && !(stopId in displayedFeatures)) {
console.log(`Add feature for ${stopId}`); console.log(`Add feature for ${stopId}`);
stopVectorSource.addFeature(feature); stopVectorSource.addFeature(feature);
displayedFeatures[stopId] = feature; displayedFeatures[stopId] = feature;
} }
} }
const extend = stopVectorSource.getExtent(); const extend = stopVectorSource.getExtent();
if (map !== undefined && !isEmptyExtend(extend)) { if (map !== undefined && !isEmptyExtend(extend)) {
map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding }); map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding });
} }
}); });
// Flashing effect // Flashing effect
createEffect(() => { createEffect(() => {
const highlightedStopId = getHighlightedStop()?.id; const highlightedStopId = getHighlightedStop()?.id;
if (highlightedStopId !== undefined) { if (highlightedStopId !== undefined) {
const stop = getStop(highlightedStopId); const stop = getStop(highlightedStopId);
if (stop !== undefined) { if (stop !== undefined) {
const stops = stop.stops ? stop.stops : [stop]; const stops = stop.stops ? stop.stops : [stop];
stops.forEach((s) => { stops.forEach((s) => {
const feature = displayedFeatures[s.id]; const feature = displayedFeatures[s.id];
if (feature !== undefined) { if (feature !== undefined) {
flash(feature); flash(feature);
} }
}); });
} }
} }
}); });
const flash = (feature: OlFeature) => { const flash = (feature: OlFeature) => {
const start = Date.now(); const start = Date.now();
const flashGeom = feature.getGeometry()?.clone(); const flashGeom = feature.getGeometry()?.clone();
const listenerKey = stopVectorLayer.on('postrender', animate); const listenerKey = stopVectorLayer.on('postrender', animate);
// Force postrender raising. // Force postrender raising.
feature.changed(); feature.changed();
function animate(event) { function animate(event) {
const frameState = event.frameState; const frameState = event.frameState;
const elapsed = frameState.time - start; const elapsed = frameState.time - start;
const vectorContext = getVectorContext(event); const vectorContext = getVectorContext(event);
if (elapsed >= flashDurationMs) { if (elapsed >= flashDurationMs) {
unByKey(listenerKey); unByKey(listenerKey);
return; return;
} }
if (flashGeom !== undefined && map !== undefined) { if (flashGeom !== undefined && map !== undefined) {
const elapsedRatio = elapsed / flashDurationMs; const elapsedRatio = elapsed / flashDurationMs;
// radius will be 5 at start and 30 at end. // radius will be 5 at start and 30 at end.
const radius = easeOut(elapsedRatio) * 25 + 5; const radius = easeOut(elapsedRatio) * 25 + 5;
const opacity = easeOut(1 - elapsedRatio); const opacity = easeOut(1 - elapsedRatio);
const style = new Style({ const style = new Style({
image: new Circle({ image: new Circle({
radius: radius, radius: radius,
stroke: new Stroke({ stroke: new Stroke({
color: `rgba(255, 0, 0, ${opacity})`, color: `rgba(255, 0, 0, ${opacity})`,
width: 0.25 + opacity, width: 0.25 + opacity,
}), }),
}), }),
}); });
vectorContext.setStyle(style); vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom); vectorContext.drawGeometry(flashGeom);
// tell OpenLayers to continue postrender animation // tell OpenLayers to continue postrender animation
map.render(); map.render();
} }
} }
} }
return <> return <>
<div ref={mapDiv} class="map"> <div ref={setMapRef!} class="map">
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} /> <StopPopup ref={popup!} stop={selectedMapStop()} show={isPopupDisplayed()} />
</div> </div>
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For> <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 // TODO: Use boolean to set MapStop selected
export const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => { export const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext); const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined) if (businessDataStore === undefined || searchStore === undefined)
return <div />; return <div />;
const { getStopShape } = businessDataStore; const { getStopShape } = businessDataStore;
const { setMapFeature } = searchStore; const { setMapFeature } = searchStore;
const stopStyle = new Style({ const stopStyle = new Style({
image: new Circle({ image: new Circle({
fill: undefined, fill: undefined,
stroke: new Stroke({ color: '#3399CC', width: 1.5 }), stroke: new Stroke({ color: '#3399CC', width: 1.5 }),
radius: 10, radius: 10,
}), }),
}); });
const selectedStopStyle = new Style({ const selectedStopStyle = new Style({
image: new Circle({ image: new Circle({
fill: undefined, fill: undefined,
stroke: new Stroke({ color: 'purple', width: 2 }), stroke: new Stroke({ color: 'purple', width: 2 }),
radius: 10, radius: 10,
}), }),
}); });
const stopAreaStyle = new Style({ const stopAreaStyle = new Style({
stroke: new Stroke({ color: 'red' }), stroke: new Stroke({ color: 'red' }),
fill: new Fill({ color: 'rgba(255,255,255,0.2)' }), fill: new Fill({ color: 'rgba(255,255,255,0.2)' }),
}); });
const getShape = async (stopId: number): Promise<StopShape | undefined> => { const getShape = async (stopId: number): Promise<StopShape | undefined> => {
return await getStopShape(stopId); return await getStopShape(stopId);
}; };
const [shape] = createResource<StopShape | undefined, number>(props.stop.id, getShape); const [shape] = createResource<StopShape | undefined, number>(props.stop.id, getShape);
createEffect(() => { createEffect(() => {
const shape_ = shape(); const shape_ = shape();
if (shape_ === undefined) { if (shape_ === undefined) {
return; return;
} }
let feature = undefined; let feature = undefined;
if (props.stop.epsg3857_x !== undefined && props.stop.epsg3857_y !== undefined) { if (props.stop.epsg3857_x !== undefined && props.stop.epsg3857_y !== undefined) {
const selectStopStyle = () => { const selectStopStyle = () => {
return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle); return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle);
} }
feature = new OlFeature({ feature = new OlFeature({
geometry: new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]), geometry: new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]),
}); });
feature.setStyle(selectStopStyle); feature.setStyle(selectStopStyle);
} }
else { else {
let geometry = undefined; let geometry = undefined;
const areaShape = shape(); const areaShape = shape();
if (areaShape !== undefined) { if (areaShape !== undefined) {
geometry = new OlPolygon([areaShape.epsg3857_points.slice(0, -1)]); geometry = new OlPolygon([areaShape.epsg3857_points.slice(0, -1)]);
} }
else { else {
geometry = new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]); geometry = new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]);
} }
feature = new OlFeature({ geometry: geometry }); feature = new OlFeature({ geometry: geometry });
feature.setStyle(stopAreaStyle); feature.setStyle(stopAreaStyle);
} }
feature.setId(props.stop.id); 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 { export interface SearchStore {
getSearchText: () => string; getSearchText: () => string;
setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>; setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>;
getFoundStops: () => Stop[]; getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void; setFoundStops: (stops: Stop[]) => void;
getDisplayedPanelId: () => number; getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void; setDisplayedPanelId: (panelId: number) => void;
getPanels: () => PositionedPanel[]; getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void; setPanels: (panels: PositionedPanel[]) => void;
getHighlightedStop: () => Stop | undefined; getHighlightedStop: () => Stop | undefined;
setHighlightedStop: (stop: Stop) => void; setHighlightedStop: (stop: Stop) => void;
resetHighlightedStop: () => void; resetHighlightedStop: () => void;
enableMap: (enable: boolean) => void; enableMap: (enable: boolean) => void;
isMapEnabled: () => boolean; isMapEnabled: () => boolean;
getMapFeature: (stopId: number) => OlFeature | undefined; getMapFeature: (stopId: number) => OlFeature | undefined;
getAllMapFeatures: () => ByStopIdMapFeatures; getAllMapFeatures: () => ByStopIdMapFeatures;
setMapFeature: (stopId: number, feature: OlFeature) => void; setMapFeature: (stopId: number, feature: OlFeature) => void;
}; };
export const SearchContext = createContext<SearchStore>(); export const SearchContext = createContext<SearchStore>();
@@ -38,128 +38,126 @@ export const SearchContext = createContext<SearchStore>();
export function SearchProvider(props: { children: JSX.Element }) { export function SearchProvider(props: { children: JSX.Element }) {
const searchTextDelayMs = 1500; const searchTextDelayMs = 1500;
type Store = { type Store = {
searchText: string; searchText: string;
searchPromise: Promise<void> | undefined; searchPromise: Promise<void> | undefined;
foundStops: Stop[]; foundStops: Stop[];
displayedPanelId: number; displayedPanelId: number;
panels: PositionedPanel[]; panels: PositionedPanel[];
highlightedStop: Stop | undefined; highlightedStop: Stop | undefined;
mapEnabled: boolean; mapEnabled: boolean;
mapFeatures: ByStopIdMapFeatures; mapFeatures: ByStopIdMapFeatures;
}; };
const [store, setStore] = createStore<Store>({ const [store, setStore] = createStore<Store>({
searchText: "", searchText: "",
searchPromise: undefined, searchPromise: undefined,
foundStops: [], foundStops: [],
displayedPanelId: 0, displayedPanelId: 0,
panels: [], panels: [],
highlightedStop: undefined, highlightedStop: undefined,
// mapEnabled: false, // mapEnabled: false,
mapFeatures: {}, mapFeatures: {},
}); });
const getSearchText = (): string => { const getSearchText = (): string => {
return store.searchText; return store.searchText;
} }
const debounce = async (fn: (...args: any[]) => Promise<void>, delayMs: number) => { const debounce = async (fn: (...args: any[]) => Promise<void>, delayMs: number) => {
let timerId: number; let timerId: number;
return new Promise((...args) => { return new Promise((...args) => {
clearTimeout(timerId); clearTimeout(timerId);
timerId = setTimeout(fn, delayMs, ...args); timerId = setTimeout(fn, delayMs, ...args);
}); });
} }
const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => { const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => {
setStore('searchText', text); setStore('searchText', text);
if (store.searchPromise === undefined) { if (store.searchPromise === undefined) {
const { searchStopByName } = businessDataStore; const { searchStopByName } = businessDataStore;
const promise: Promise<void> = debounce(async (onSuccess: () => void) => { const promise: Promise<void> = debounce(async (onSuccess: () => void) => {
console.log(`Fetching data for "${store.searchText}" stop name`); console.log(`Fetching data for "${store.searchText}" stop name`);
const stopsById = await searchStopByName(store.searchText); const stopsById = await searchStopByName(store.searchText);
console.log("stopsById=", stopsById); console.log("stopsById=", stopsById);
setFoundStops(Object.values(stopsById)); setFoundStops(Object.values(stopsById));
onSuccess(); onSuccess();
}, searchTextDelayMs).then(() => { }, searchTextDelayMs).then(() => {
setStore('searchPromise', undefined); setStore('searchPromise', undefined);
}); });
setStore('searchPromise', promise); setStore('searchPromise', promise);
} }
} }
const getFoundStops = (): Stop[] => { const getFoundStops = (): Stop[] => {
return store.foundStops; return store.foundStops;
} }
const setFoundStops = (stops: Stop[]): void => { const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops); setStore('foundStops', stops);
} }
const getDisplayedPanelId = (): number => { const getDisplayedPanelId = (): number => {
return store.displayedPanelId; return store.displayedPanelId;
} }
const setDisplayedPanelId = (panelId: number): void => { const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId); setStore('displayedPanelId', panelId);
} }
const getPanels = (): PositionedPanel[] => { const getPanels = (): PositionedPanel[] => {
return store.panels; return store.panels;
} }
const setPanels = (panels: PositionedPanel[]): void => { const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels); setStore('panels', panels);
} }
const getHighlightedStop = (): Stop | undefined => { const getHighlightedStop = (): Stop | undefined => {
return store.highlightedStop; return store.highlightedStop;
} }
const setHighlightedStop = (stop: Stop): void => { const setHighlightedStop = (stop: Stop): void => {
setStore('highlightedStop', stop); setStore('highlightedStop', stop);
} }
const resetHighlightedStop = (): void => { const resetHighlightedStop = (): void => {
setStore('highlightedStop', undefined); setStore('highlightedStop', undefined);
} }
const enableMap = (enable: boolean): void => { const enableMap = (enable: boolean): void => {
setStore("mapEnabled", enable); setStore("mapEnabled", enable);
} }
const isMapEnabled = (): boolean => { const isMapEnabled = (): boolean => {
return store.mapEnabled; return store.mapEnabled;
} }
const getAllMapFeatures = (): ByStopIdMapFeatures => { const getAllMapFeatures = (): ByStopIdMapFeatures => {
return store.mapFeatures; return store.mapFeatures;
} }
const getMapFeature = (stopId: number): OlFeature | undefined => { const getMapFeature = (stopId: number): OlFeature | undefined => {
return store.mapFeatures[stopId]; return store.mapFeatures[stopId];
} }
const setMapFeature = (stopId: number, feature: OlFeature): void => { const setMapFeature = (stopId: number, feature: OlFeature): void => {
setStore('mapFeatures', stopId, feature); setStore('mapFeatures', stopId, feature);
}; };
return ( return <SearchContext.Provider value={{
<SearchContext.Provider value={{ getSearchText, setSearchText,
getSearchText, setSearchText, getFoundStops, setFoundStops,
getFoundStops, setFoundStops, getDisplayedPanelId, setDisplayedPanelId,
getDisplayedPanelId, setDisplayedPanelId, getPanels, setPanels,
getPanels, setPanels, getHighlightedStop, setHighlightedStop, resetHighlightedStop,
getHighlightedStop, setHighlightedStop, resetHighlightedStop, enableMap, isMapEnabled,
enableMap, isMapEnabled, getMapFeature, getAllMapFeatures, setMapFeature,
getMapFeature, getAllMapFeatures, setMapFeature, }}>
}}> {props.children}
{props.children} </SearchContext.Provider>;
</SearchContext.Provider>
);
} }

View File

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

View File

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

View File

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

View File

@@ -3,83 +3,84 @@
.mapPlaceholder { .mapPlaceholder {
--border-width: 0.1vh; --border-width: 0.1vh;
height: calc(100% - 2*var(--border-width)); height: calc(100% - 2*var(--border-width));
width: 50%; width: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: solid var(--neutral-color) var(--border-width); border: solid var(--neutral-color) var(--border-width);
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: var(--idfm-black); background-color: var(--idfm-black);
font-family: IDFVoyageur-bold; font-family: IDFVoyageur-bold;
font-size: 2vh; font-size: 2vh;
color: var(--idfm-white); color: var(--idfm-white);
} }
.stopNameInput { .stopNameInput {
width: 50%; width: 50%;
height: 60%; height: 60%;
display: flex; display: flex;
flex-flow: row; flex-flow: row;
border: solid var(--neutral-color) calc(0.01vh); border: solid var(--neutral-color) calc(0.01vh);
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: transparent; background-color: transparent;
.leftAddon {
width: 17%;
display: flex; .leftAddon {
align-items: center; width: 17%;
justify-content: center;
background-color: var(--idfm-white); display: flex;
} align-items: center;
justify-content: center;
input { background-color: var(--idfm-white);
width: 83%; }
padding-left: 3%; input {
padding-right: 3%; width: 83%;
color: var(--idfm-white); padding-left: 3%;
font-family: IDFVoyageur-regular; padding-right: 3%;
background-color: transparent;
} color: var(--idfm-white);
font-family: IDFVoyageur-regular;
background-color: transparent;
}
} }
.title { .title {
@extend %title; @extend %title;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.stopSearchMenu { .stopSearchMenu {
@extend %widget; @extend %widget;
.body { .body {
@extend %body; @extend %body;
flex-direction: row; flex-direction: row;
.stopsPanels { .stopsPanels {
width: 50%; width: 50%;
height: 100%; height: 100%;
scroll-snap-type: y mandatory; scroll-snap-type: y mandatory;
overflow-y: scroll; overflow-y: scroll;
.displayed { .displayed {
display: block; 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 { lazily } from 'solidjs-lazily';
import { createScrollPosition } from "@solid-primitives/scroll"; 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) => { const StopNameInput: VoidComponent<{ onInput: JSX.EventHandler<HTMLInputElement, InputEvent>, leftAddon: string, placeholder: string }> = (props) => {
return ( return <div class="stopNameInput">
<div class="stopNameInput"> <div class="leftAddon">{props.leftAddon}</div>
<div class="leftAddon">{props.leftAddon}</div> <input type="text" oninput={props.onInput} placeholder={props.placeholder} />
<input type="text" oninput={props.onInput} placeholder={props.placeholder} /> </div>;
</div>);
}; };
const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => { const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext); const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined) if (businessDataStore === undefined || searchStore === undefined)
return <div />; return <div />;
const { setSearchText } = searchStore; const { setSearchText } = searchStore;
const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => { const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => {
const stopName = event.currentTarget.value; const stopName = event.currentTarget.value;
if (stopName.length >= props.minCharsNb) { if (stopName.length >= props.minCharsNb) {
await setSearchText(stopName, businessDataStore); await setSearchText(stopName, businessDataStore);
} }
} }
return ( return <div class="header">
<div class="header"> <div class="title">
<div class="title"> <svg viewBox="0 0 1260 50">
<svg viewBox="0 0 1260 50"> <text x="0" y="50%" dominant-baseline="middle" font-size="50" style="fill: #ffffff">
<text x="0" y="50%" dominant-baseline="middle" font-size="50" style="fill: #ffffff"> {props.title}
{props.title} </text>
</text> </svg>
</svg> </div>
</div> <StopNameInput onInput={onStopNameInput} leftAddon="🚉 🚏" placeholder="Stop name..." />
<StopNameInput onInput={onStopNameInput} leftAddon="🚉 🚏" placeholder="Stop name..." /> </div>;
</div >
);
}; };
const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => { const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
const searchStore: SearchStore | undefined = useContext(SearchContext); const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) { if (searchStore === undefined) {
return <div />; return <div />;
} }
const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore; const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore;
let stopsPanelsRef: HTMLDivElement | undefined = undefined let stopsPanelsRef: HTMLDivElement | undefined = undefined
const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef); const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef);
const yStopsPanelsScroll = () => stopsPanelsScroll.y; const yStopsPanelsScroll = () => stopsPanelsScroll.y;
createEffect(() => { createEffect(() => {
yStopsPanelsScroll(); yStopsPanelsScroll();
for (const panel of getPanels()) { for (const panel of getPanels()) {
const panelDiv = panel.panel(); const panelDiv = panel.panel;
const panelDivClientRect = panelDiv.getBoundingClientRect(); if (panelDiv != null) {
if (panelDivClientRect.y > 0) { const panelDivClientRect = panelDiv.getBoundingClientRect();
setDisplayedPanelId(panel.position); if (panelDivClientRect.y > 0) {
break; setDisplayedPanelId(panel.position);
} break;
} }
}); }
}
});
return ( return (
<div ref={stopsPanelsRef} class="stopsPanels"> <div ref={stopsPanelsRef} class="stopsPanels">
{() => { {() => {
setPanels([]); setPanels([]);
let newPanels = []; let newPanels = [];
let positioneds: PositionedPanel[] = []; let positioneds: PositionedPanel[] = [];
let stops: Stop[] = []; let stops: Stop[] = [];
for (const stop of getFoundStops()) { for (const stop of getFoundStops()) {
if (stops.length < props.maxStopsPerPanel) { if (stops.length < props.maxStopsPerPanel) {
stops.push(stop); stops.push(stop);
} }
else { else {
const panelId = newPanels.length; const panelId = newPanels.length;
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />; const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel); newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel }); positioneds.push({ position: panelId, panel: panel });
stops = [stop]; stops = [stop];
} }
} }
if (stops.length) { if (stops.length) {
const panelId = newPanels.length; const panelId = newPanels.length;
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />; const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel); newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel }); positioneds.push({ position: panelId, panel: panel });
} }
setPanels(positioneds); setPanels(positioneds);
return newPanels; return newPanels;
}} }}
</div> </div>
); );
}; };
const MapPlaceholder: VoidComponent<{}> = () => { const MapPlaceholder: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext); const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) { if (searchStore === undefined) {
return <div />; return <div />;
} }
const { enableMap } = searchStore; const { enableMap } = searchStore;
const onDoubleClick = (): void => { const onDoubleClick = (): void => {
console.log('!!! ON DOUBLE CLICK'); console.log('!!! ON DOUBLE CLICK');
enableMap(true); enableMap(true);
} }
return <div return <div
class="mapPlaceholder" ondblclick={() => onDoubleClick()}> class="mapPlaceholder"
Double-clic pour activer la carte ondblclick={() => onDoubleClick()}>
</div>; Double-clic pour activer la carte
</div>;
}; };
const Body: VoidComponent<{}> = () => { const Body: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext); const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) { if (searchStore === undefined) {
return <div />; return <div />;
} }
const { isMapEnabled } = searchStore; const { isMapEnabled } = searchStore;
const maxStopsPerPanel = 5; const maxStopsPerPanel = 5;
return <div class="body"> return <div class="body">
<StopsPanels maxStopsPerPanel={maxStopsPerPanel} /> <StopsPanels maxStopsPerPanel={maxStopsPerPanel} />
<Show when={isMapEnabled()} fallback={<MapPlaceholder />}> <Show when={isMapEnabled()} fallback={<MapPlaceholder />}>
<Map /> <Map />
</Show> </Show>
</div>; </div>;
}; };
const Footer: VoidComponent<{}> = () => { const Footer: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext); const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) { if (searchStore === undefined) {
return <div />; return <div />;
} }
const { getDisplayedPanelId, getPanels } = searchStore; const { getDisplayedPanelId, getPanels } = searchStore;
return ( return <div class="footer">
<div class="footer"> <For each={getPanels()}>
<For each={getPanels()}> {(panel) => {
{(panel) => { const position = panel.position;
const position = panel.position; return (
return ( <div>
<div> <svg viewBox="0 0 29 29">
<svg viewBox="0 0 29 29"> <circle
<circle cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3" cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3"
style={{ fill: `var(--idfm-${position == getDisplayedPanelId() ? "white" : "black"})` }} style={{ fill: `var(--idfm-${position == getDisplayedPanelId() ? "white" : "black"})` }}
/> />
</svg> </svg>
</div> </div>
); );
}} }}</For>
</For> </div>;
</div>
);
}; };
export const StopsSearchMenu: VoidComponent = () => { export const StopsSearchMenu: VoidComponent = () => {
return ( return <div class="stopSearchMenu">
<div class="stopSearchMenu"> <SearchProvider>
<SearchProvider> <Header title="Recherche de l'arrêt..." minCharsNb={4} />
<Header title="Recherche de l'arrêt..." minCharsNb={4} /> <Body />
<Body /> <Footer />
<Footer /> </SearchProvider>
</SearchProvider> </div>;
</div>
);
}; };

View File

@@ -1,116 +1,116 @@
export enum TrafficStatus { export enum TrafficStatus {
UNKNOWN = 0, UNKNOWN = 0,
FLUID, FLUID,
DISRUPTED, DISRUPTED,
VERY_DISRUPTED, VERY_DISRUPTED,
BYPASSED BYPASSED
} }
export class Passage { export class Passage {
line: number; line: number;
operator: number; operator: number;
destinations: string[]; destinations: string[];
atStop: boolean; atStop: boolean;
aimedArrivalTs: number; aimedArrivalTs: number;
expectedArrivalTs: number; expectedArrivalTs: number;
arrivalPlatformName: string; arrivalPlatformName: string;
aimedDepartTs: number; aimedDepartTs: number;
expectedDepartTs: number; expectedDepartTs: number;
arrivalStatus: string; arrivalStatus: string;
departStatus: string; departStatus: string;
constructor(line: number, operator: number, destinations: string[], atStop: boolean, aimedArrivalTs: number, constructor(line: number, operator: number, destinations: string[], atStop: boolean, aimedArrivalTs: number,
expectedArrivalTs: number, arrivalPlatformName: string, aimedDepartTs: number, expectedDepartTs: number, expectedArrivalTs: number, arrivalPlatformName: string, aimedDepartTs: number, expectedDepartTs: number,
arrivalStatus: string, departStatus: string) { arrivalStatus: string, departStatus: string) {
this.line = line; this.line = line;
this.operator = operator; this.operator = operator;
this.destinations = destinations; this.destinations = destinations;
this.atStop = atStop; this.atStop = atStop;
this.aimedArrivalTs = aimedArrivalTs; this.aimedArrivalTs = aimedArrivalTs;
this.expectedArrivalTs = expectedArrivalTs; this.expectedArrivalTs = expectedArrivalTs;
this.arrivalPlatformName = arrivalPlatformName; this.arrivalPlatformName = arrivalPlatformName;
this.aimedDepartTs = aimedDepartTs; this.aimedDepartTs = aimedDepartTs;
this.expectedDepartTs = expectedDepartTs; this.expectedDepartTs = expectedDepartTs;
this.arrivalStatus = arrivalStatus; this.arrivalStatus = arrivalStatus;
this.departStatus = departStatus; this.departStatus = departStatus;
} }
}; }
export type Passages = Record<string, Record<string, Passage[]>>; export type Passages = Record<string, Record<string, Passage[]>>;
export class Stop { export class Stop {
id: number; id: number;
name: string; name: string;
town: string; town: string;
epsg3857_x: number; epsg3857_x: number;
epsg3857_y: number; epsg3857_y: number;
stops: Stop[]; stops: Stop[];
lines: number[]; lines: number[];
constructor(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.id = id;
this.name = name; this.name = name;
this.town = town; this.town = town;
this.epsg3857_x = epsg3857_x; this.epsg3857_x = epsg3857_x;
this.epsg3857_y = epsg3857_y; this.epsg3857_y = epsg3857_y;
this.stops = stops; this.stops = stops;
this.lines = lines; this.lines = lines;
for (const stop of this.stops) { for (const stop of this.stops) {
this.lines.push(...stop.lines); this.lines.push(...stop.lines);
} }
} }
}; }
export type Stops = Record<number, Stop>; export type Stops = Record<number, Stop>;
export type Points = [number, number][]; export type Points = [number, number][];
export class StopShape { export class StopShape {
stop_id: number; stop_id: number;
type_: number; type_: number;
epsg3857_bbox: number[]; epsg3857_bbox: number[];
epsg3857_points: Points; epsg3857_points: Points;
constructor(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.stop_id = stop_id;
this.type_ = type_; this.type_ = type_;
this.epsg3857_bbox = epsg3857_bbox; this.epsg3857_bbox = epsg3857_bbox;
this.epsg3857_points = epsg3857_points; this.epsg3857_points = epsg3857_points;
} }
}; }
export type StopShapes = Record<number, StopShape>; export type StopShapes = Record<number, StopShape>;
export class Line { export class Line {
id: number; id: number;
shortName: string; shortName: string;
name: string; name: string;
status: string; // TODO: Use an enum status: string; // TODO: Use an enum
transportMode: string; // TODO: Use an enum transportMode: string; // TODO: Use an enum
backColorHexa: string; backColorHexa: string;
foreColorHexa: string; foreColorHexa: string;
operatorId: number; operatorId: number;
accessibility: boolean; accessibility: boolean;
visualSignsAvailable: string; // TODO: Use an enum visualSignsAvailable: string; // TODO: Use an enum
audibleSignsAvailable: string; // TODO: Use an enum audibleSignsAvailable: string; // TODO: Use an enum
stopIds: number[]; stopIds: number[];
constructor(id: number, shortName: string, name: string, status: string, transportMode: string, backColorHexa: string, constructor(id: number, shortName: string, name: string, status: string, transportMode: string, backColorHexa: string,
foreColorHexa: string, operatorId: number, accessibility: boolean, visualSignsAvailable: string, foreColorHexa: string, operatorId: number, accessibility: boolean, visualSignsAvailable: string,
audibleSignsAvailable: string, stopIds: number[]) { audibleSignsAvailable: string, stopIds: number[]) {
this.id = id; this.id = id;
this.shortName = shortName; this.shortName = shortName;
this.name = name; this.name = name;
this.status = status; this.status = status;
this.transportMode = transportMode; this.transportMode = transportMode;
this.backColorHexa = backColorHexa; this.backColorHexa = backColorHexa;
this.foreColorHexa = foreColorHexa; this.foreColorHexa = foreColorHexa;
this.operatorId = operatorId; this.operatorId = operatorId;
this.accessibility = accessibility; this.accessibility = accessibility;
this.visualSignsAvailable = visualSignsAvailable; this.visualSignsAvailable = visualSignsAvailable;
this.audibleSignsAvailable = audibleSignsAvailable; this.audibleSignsAvailable = audibleSignsAvailable;
this.stopIds = stopIds; this.stopIds = stopIds;
} }
}; }
export type Lines = Record<string, Line>; 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 // Thanks to https://dev.to/ycmjason/how-to-create-range-in-javascript-539i
export function* range(start: number, end: number): Generator<number> { export function* range(start: number, end: number): Generator<number> {
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
yield i; yield i;
} }
} }
const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"]; const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"];
export const TransportModeWeights: Record<string, number> = { export const TransportModeWeights: Record<string, number> = {
bus: 1, bus: 1,
tram: 2, tram: 2,
val: 3, val: 3,
funicular: 4, funicular: 4,
metro: 5, metro: 5,
rer: 6, rer: 6,
transilien: 7, transilien: 7,
ter: 8, ter: 8,
}; };
export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined { export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined {
let ret = undefined; let ret = undefined;
if (validTransportModes.includes(mode)) { if (validTransportModes.includes(mode)) {
return `/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`; return `/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
} }
return ret; return ret;
} }
export function renderLineTransportMode(line: Line): JSX.Element { 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 { function renderBusLinePicto(line: Line): JSX.Element {
return ( return <div class="busLinePicto">
<div class="busLinePicto"> <svg viewBox="0 0 31.5 14">
<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}` }} />
<rect x="0" y="0" width="31.5" height="14" rx="1.5" ry="1.5" style={{ fill: `#${line.backColorHexa}` }} /> <text
<text x="50%" x="50%"
y="55%" y="55%"
dominant-baseline="middle" dominant-baseline="middle"
text-anchor="middle" text-anchor="middle"
font-size="7.4" font-size="7.4"
style={{ fill: `#${line.foreColorHexa}` }}> style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName} {line.shortName}
</text> </text>
</svg> </svg>
</div> </div>;
);
} }
function renderTramLinePicto(line: Line): JSX.Element { function renderTramLinePicto(line: Line): JSX.Element {
const lineStyle = { fill: `#${line.backColorHexa}` }; const lineStyle = { fill: `#${line.backColorHexa}` };
return ( return <div class="tramLinePicto">
<div class="tramLinePicto"> <svg viewBox="0 0 20 20">
<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="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} />
<rect x="0" y="17" width="20" height="3" rx="1" ry="1" style={lineStyle} /> <text
<text x="50%" x="50%"
y="55%" y="55%"
dominant-baseline="middle" dominant-baseline="middle"
text-anchor="middle" text-anchor="middle"
font-size="11" font-size="11"
style={{ fill: "#00000" }}> style={{ fill: "#00000" }}>
{line.shortName} {line.shortName}
</text> </text>
</svg> </svg>
</div> </div>;
);
} }
function renderMetroLinePicto(line: Line): JSX.Element { function renderMetroLinePicto(line: Line): JSX.Element {
return ( return <div class="metroLinePicto">
<div class="metroLinePicto"> <svg viewBox="0 0 20 20">
<svg viewBox="0 0 20 20"> <circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} />
<circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} /> <text
<text x="50%" x="50%"
y="55%" y="55%"
dominant-baseline="middle" dominant-baseline="middle"
text-anchor="middle" text-anchor="middle"
font-size="11" style={{ fill: `#${line.foreColorHexa}` }}> font-size="11" style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName} {line.shortName}
</text> </text>
</svg> </svg>
</div> </div>;
);
} }
function renderTrainLinePicto(line: Line): JSX.Element { function renderTrainLinePicto(line: Line): JSX.Element {
return ( return <div class="trainLinePicto">
<div class="trainLinePicto"> <svg viewBox="0 0 20 20">
<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}` }} />
<rect x="0" y="0" width="20" height="20" rx="4.5" ry="4.5" style={{ fill: `#${line.backColorHexa}` }} /> <text
<text x="50%" x="50%"
y="55%" y="55%"
dominant-baseline="middle" dominant-baseline="middle"
text-anchor="middle" text-anchor="middle"
font-size="11" font-size="11"
style={{ fill: `#${line.foreColorHexa}` }}> style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName} {line.shortName}
</text> </text>
</svg> </svg>
</div> </div>;
);
} }
export function renderLinePicto(line: Line): JSX.Element { export function renderLinePicto(line: Line): JSX.Element {
switch (line.transportMode) { switch (line.transportMode) {
case "bus": case "bus":
case "funicular": case "funicular":
return renderBusLinePicto(line); return renderBusLinePicto(line);
case "tram": case "tram":
return renderTramLinePicto(line); return renderTramLinePicto(line);
/* case "val": */ /* case "val": */
case "metro": case "metro":
return renderMetroLinePicto(line); return renderMetroLinePicto(line);
case "transilien": case "transilien":
case "rer": case "rer":
case "ter": case "ter":
return renderTrainLinePicto(line); return renderTrainLinePicto(line);
} }
} }
export type PositionedPanel = { export type PositionedPanel = {
position: number; position: number;
// TODO: Should be PassagesPanelComponent ? // TODO: Should be PassagesPanelComponent ?
panel: JSX.Element; panel: JSX.Element;
}; };
export const ScrollingText: VoidComponent<{ height: number, width: number, content: string }> = (props) => { export const ScrollingText: VoidComponent<{ height: number, width: number, content: string }> = (props) => {
let viewBoxRef: SVGSVGElement | undefined = undefined; let viewBoxRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined; let textRef: SVGTextElement | undefined = undefined;
onMount(() => { onMount(() => {
if (viewBoxRef !== undefined && textRef !== undefined) { if (viewBoxRef !== undefined && textRef !== undefined) {
const overlap = textRef.getComputedTextLength() - viewBoxRef.viewBox.baseVal.width; const overlap = textRef.getComputedTextLength() - viewBoxRef.viewBox.baseVal.width;
if (overlap > 0) { if (overlap > 0) {
timeline( timeline(
[ [
[textRef, { x: [-overlap] }, { duration: 5 }], [textRef, { x: [-overlap] }, { duration: 5 }],
[textRef, { x: [0] }, { duration: 2 }], [textRef, { x: [0] }, { duration: 2 }],
], ],
{ repeat: Infinity }, { repeat: Infinity },
); );
} }
} }
}); });
return ( return <svg ref={viewBoxRef} viewBox={`0 0 ${props.width} ${props.height}`}>
<svg ref={viewBoxRef} viewBox={`0 0 ${props.width} ${props.height}`}> <text
<text ref={textRef}
ref={textRef} x="0%" y="55%"
x="0%" y="55%" dominant-baseline="middle"
dominant-baseline="middle" font-size={`${props.height}px`}>
font-size={`${props.height}px`}> {props.content}
{props.content} </text>
</text> </svg>;
</svg >
);
} }

View File

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