🎉 First commit !!!

This commit is contained in:
2023-01-22 16:53:45 +01:00
commit dde835760a
68 changed files with 3250 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
.App {
--idfm-black: #2c2e35;
--idfm-white: #ffffff;
height: inherit;
width: inherit;
scroll-snap-type: x mandatory;
overflow-x: scroll;
display: flex;
text-align: center;
}
.panel {
min-width: 100%;
height: inherit;
width: inherit;
scroll-snap-align: center;
}

65
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { Component } from 'solid-js';
import { MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction, CustomEvent, IVisibilityActionRequest } from 'matrix-widget-api';
import { HopeProvider } from "@hope-ui/solid";
import { BusinessDataProvider } from './businessData';
import { SearchProvider } from './search';
import { NextPassagesDisplay } from './nextPassagesDisplay';
import { StopsManager } from './stopsManager';
import styles from './App.module.css';
function parseFragment() {
const fragmentString = (window.location.hash || "?");
return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0)));
}
const App: Component = () => {
console.log('App: New');
const qs = parseFragment();
const widgetId = qs.get('widgetId');
const userId = qs.get('userId');
console.log("App: widgetId:" + widgetId);
console.log("App: userId:" + userId);
const api = new WidgetApi(widgetId);
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
api.start();
api.on("ready", function() {
console.log("App: widget API is READY !!!!");
});
// Seems to don´t be used...
api.on(`action:${WidgetApiToWidgetAction.UpdateVisibility}`, (ev: CustomEvent<IVisibilityActionRequest>) => {
console.log("App: Visibility change");
ev.preventDefault(); // we're handling it, so stop the widget API from doing something.
console.log("App: ", ev.detail); // custom handling here
/* api.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); */
api.transport.reply(ev.detail, {});
});
return (
<BusinessDataProvider>
<SearchProvider>
<HopeProvider>
<div class={styles.App} data-panelsnap-id="1">
<div class={styles.panel}>
<StopsManager />
</div>
<div class={styles.panel}>
<NextPassagesDisplay />
</div>
</div>
</HopeProvider>
</SearchProvider>
</BusinessDataProvider>
);
};
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,79 @@
import { createContext, createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Passages, Stops } from './types';
interface Store {
passages: () => Passages;
getLinePassages?: (lineId: string) => Passages;
addPassages?: (passages) => void;
clearPassages?: () => void;
stops: () => Stops;
addStops?: (stops) => void;
};
export const BusinessDataContext = createContext<Store>();
export function BusinessDataProvider(props: { children: JSX.Element }) {
const [serverUrl, setServerUrl] = createSignal<string>("https://localhost:4443");
const [store, setStore] = createStore({ lines: {}, passages: {}, stops: {} });
async function getLine(lineId: number) {
let line = store.lines[lineId];
if (line === undefined) {
console.log(`${lineId} not found... fetch it from backend.`);
const data = await fetch(`${serverUrl()}/line/${lineId}`, {
headers: { 'Content-Type': 'application/json' }
});
line = await data.json();
setStore('lines', lineId, line);
}
return line;
}
const passages = () => {
return store.passages;
};
const getLinePassages = (lineId: string) => {
return store.passages[lineId];
};
const addPassages = (passages) => {
setStore((s) => {
// console.log("s=", s);
setStore('passages', passages);
// console.log("s=", s);
});
}
const clearPassages = () => {
setStore((s) => {
// TODO: Really need to set to undefined to reset ?
console.log("s=", s);
console.log("s.passages=", s.passages);
// setStore('passages', undefined);
// setStore('passages', {});
console.log("Object.keys(s.passages)=", Object.keys(s.passages));
for (const lineId of Object.keys(s.passages)) {
console.log("lineId=", lineId);
setStore('passages', lineId, undefined);
}
console.log("s=", s);
});
// setStore('passages', undefined);
// setStore('passages', {});
// }
console.log("passages=", store.passages);
}
return (
<BusinessDataContext.Provider value={{ getLine, passages, getLinePassages, addPassages, clearPassages, serverUrl }}>
{props.children}
</BusinessDataContext.Provider>
);
}

28
frontend/src/index.css Normal file
View File

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

7
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,7 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import './index.css';
import App from './App';
render(() => (<App/>), document.getElementById('root') as HTMLElement);

View File

@@ -0,0 +1,226 @@
/* TODO: Remove this class */
.ar16x9 {
aspect-ratio: 16 / 9;
}
/* Idfm: 1860x1080px */
.NextPassagesDisplay {
aspect-ratio: 16/9;
--reverse-aspect-ratio: 9/16;
/* height is set according to the aspect-ratio, don´t touch it */
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--idfm-black);
}
/* Idfm: 1800x100px (margin: 17px 60px) */
.header {
width: calc(1800/1920*100%);
height: calc(100/1080*100%);
/*Percentage margin are computed relatively to the nearest block container's width, not height */
/* cf. https://developer.mozilla.org/en-US/docs/Web/CSS/margin-bottom */
margin: calc(17/1080*var(--reverse-aspect-ratio)*100%) calc(60/1920*100%);
display: flex;
align-items: center;
font-family: IDFVoyageur-bold;
}
.header .transportMode {
height: 100%;
margin: 0;
margin-right: calc(23/1920*100%);
}
.header .title {
height: 50%;
width: 70%;
margin-right: auto;
}
.header .clock {
width: calc(175/1920*100%);
height: calc(80/100*100%);
display: flex;
align-items: center;
justify-content: center;
border:solid var(--idfm-white) 3px;
border-radius: calc(9/86*100%);
}
.header .clock svg {
aspect-ratio: 2.45;
height: calc(0.7*100%);
}
/* Idfm: 1860x892px (margin: 0px 30px) */
.panelsContainer {
width: calc(1860/1920*100%);
height: calc(892/1080*100%);
margin: 0 calc(30/1920*100%);
display: flex;
flex-direction: column;
background-color: white;
border-collapse:separate;
border:solid var(--idfm-black) 1px;
border-radius: calc(15/1920*100%);
}
.nextPassagesContainer {
height: 100%;
width: 100%;
display: none;
position: relative;
}
.nextPassagesContainer .line:last-child {
border-bottom: 0;
/* To make up for the bottom border deletion */
padding-bottom: calc(2px);
}
.displayed {
display: block;
}
/* Idfm: 1880x176px (margin: 0px 20px) */
.line {
width: calc(1880/1920*100%);
height: calc(100% / 5);
margin: 0 calc(20/1920*100%);
display: flex;
align-items: center;
/* TODO: compute the border weight according to the parent height */
/* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px);
}
.line svg {
font-family: IDFVoyageur-bold;
max-width: 100%;
max-height: 100%;
}
/* Idfm: 100x100px (margin: 0px 15px) */
.transportMode {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin: 0 calc(15/1920*100%);
}
.tramLinePicto {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin-right: calc(23/1920*100%);
}
.busLinePicto {
aspect-ratio : 2.25;
height: calc(70/176*100%);
margin-right: calc(23/1920*100%);
}
.destination {
height: calc(60/176*100%);
width: 50%;
font-family: IDFVoyageur-bold;
text-align: left;
}
.trafficStatus {
height: calc(50/176*100%);
aspect-ratio: 35/50;
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
}
.trafficStatus svg {
width: 100%;
}
.firstPassage {
height: calc(100/176*100%);
aspect-ratio: 2.5;
display: flex;
align-items: center;
justify-content: center;
padding-right: calc(30/1920*100%);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
}
.unavailableFirstPassage {
height: calc(100/176*100%);
aspect-ratio: calc(230/100);
margin-right: calc(30/1920*100%);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
}
.firstPassage svg {
aspect-ratio: 215/50;
height: calc(1/2*100%);
}
.secondPassage {
height: calc(45/176*100%);
aspect-ratio: calc(230/45);
margin-right: calc(30/1920*100%);
}
.secondPassage svg {
font-family: IDFVoyageur-regular;
}
.unavailableSecondPassage {
height: calc(100/176*100%);
aspect-ratio: calc(230/100);
margin-right: calc(30/1920*100%);
}
.unavailableSecondNextPassage svg {
font-family: IDFVoyageur-regular;
}
/* Idfm: 1800x54px (margin: 0px 50px) */
.footer {
width: calc(1820/1920*100%);
height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%);
display: flex;
align-items: center;
justify-content: right;
}
.footer div {
aspect-ratio: 1;
height: 50%;
margin-left: calc(42/1920*100%);
}

View File

@@ -0,0 +1,253 @@
import { Component, createEffect, createSignal, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { createDateNow } from "@solid-primitives/date";
import { format } from "date-fns";
import { getTransportModeSrc } from "./types";
import { BusinessDataContext } from "./businessData";
import { NextPassagesPanel } from "./nextPassagesPanel";
import { SearchContext } from "./search";
import styles from "./nextPassagesDisplay.module.css";
export const NextPassagesDisplay: Component = () => {
const maxPassagePerPanel = 5;
const syncPeriodMsec = 20 * 1000;
const { passages, getLinePassages, addPassages, clearPassages, serverUrl } =
useContext(BusinessDataContext);
const { getDisplayedStop } = useContext(SearchContext);
const [panels, setPanels] = createStore([]);
const [displayedPanelId, setDisplayedPanelId] = createSignal<number>(0);
let _lines = new Map();
const [dateNow] = createDateNow(1000);
const panelSwapInterval = setInterval(() => {
let nextPanelId = displayedPanelId() + 1;
if (nextPanelId >= panels.length) {
nextPanelId = 0;
}
/* console.log(`Display panel #${nextPanelId}`); */
setDisplayedPanelId(nextPanelId);
}, 4000);
createEffect(() => {
console.log("######### onStopIdUpdate #########");
// Track local.stopIp to force dependency.
console.log("getDisplayedStop=", getDisplayedStop());
clearPassages();
});
createEffect(async () => {
console.log(`## OnPassageUpdate ${passages()} ##`);
/* console.log(passages()); */
await requestPassages();
});
async function _fetchLine(lineId: string) {
if (!_lines.has(lineId)) {
const data = await fetch(`${serverUrl()}/line/${lineId}`, {
headers: { "Content-Type": "application/json" },
});
const line = await data.json();
_lines.set(line.id, line);
}
}
async function requestPassages() {
console.log("### requestPassages ###");
/* TODO: Manage several displays (one by stop) */
const stops = getDisplayedStop();
if (stops.length == 0) {
return;
}
const stop = stops[0];
const httpOptions = { headers: { "Content-Type": "application/json" } };
if (stop !== undefined) {
const stopId = stop.id;
console.log(`Fetching data for ${stopId}`);
const url = `${serverUrl()}/stop/nextPassages/${stopId}`;
/* console.log(`url=${url}`); */
const data = await fetch(url, httpOptions);
const response = await data.json();
/* console.log(response); */
const byLineByDstPassages = response.passages;
/* console.log(byLineByDstPassages); */
const linePromises = [];
for (const lineId of Object.keys(byLineByDstPassages)) {
linePromises.push(_fetchLine(lineId));
}
await Promise.all(linePromises);
console.log("byLineByDstPassages=", byLineByDstPassages);
// console.log("before addPassages passages=", passages());
addPassages(byLineByDstPassages);
console.log("AFTER passages=", passages());
}
}
setInterval(
// const nextPassagesRequestsInterval = setTimeout(
async () => {
await requestPassages();
},
syncPeriodMsec
);
// TODO: Sort transport modes by weight
// TODO: Split this method to isolate the nextPassagesPanel part.
function _computeHeader(title: string): JSX.Element {
let transportModes = [];
transportModes = new Set(
Object.keys(passages()).map((lineId) => {
const line = _lines.get(lineId);
if (line !== undefined) {
return getTransportModeSrc(line.transportMode, false);
}
return null;
})
);
return (
<div class={styles.header}>
<For each={Array.from(transportModes)}>
{(transportMode) => {
return (
<div class={styles.transportMode}>
<img src={transportMode} />
</div>
);
}}
</For>
<div class={styles.title}>
<svg viewbox="0 0 1260 50">
<text
x="0"
y="50%"
dominant-baseline="middle"
font-size="50"
style="fill: #ffffff"
>
{title}
</text>
</svg>
</div>
<div class={styles.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>
);
}
function _computeFooter(): JSX.Element {
return (
<div class={styles.footer}>
<For each={panels}>
{(positioned) => {
const { position, panel } = positioned;
const circleStyle = {
fill: `var(--idfm-${position == displayedPanelId() ? "white" : "black"
})`,
};
return (
<div>
<svg viewBox="0 0 29 29">
<circle
cx="50%"
cy="50%"
r="13"
stroke="#ffffff"
stroke-width="3"
style={circleStyle}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}
const mainDivClasses = `${styles.NextPassagesDisplay} ${styles.ar16x9}`;
return (
<div class={mainDivClasses}>
{_computeHeader("Prochains passages")}
<div class={styles.panelsContainer}>
{() => {
setPanels([]);
let newPanels = [];
let positioneds = [];
let index = 0;
let chunk = {};
let chunkSize = 0;
console.log("passages=", passages());
for (const lineId of Object.keys(passages())) {
console.log("lineId=", lineId);
const byLinePassages = getLinePassages(lineId);
console.log("byLinePassages=", byLinePassages);
const byLinePassagesKeys = Object.keys(byLinePassages);
console.log("byLinePassagesKeys=", byLinePassagesKeys);
if (byLinePassagesKeys.length <= maxPassagePerPanel - chunkSize) {
chunk[lineId] = byLinePassages;
chunkSize += byLinePassagesKeys.length;
} else {
console.log("chunk=", chunk);
const [store, setStore] = createStore(chunk);
const panelid = index++;
const panel = (
<NextPassagesPanel
show={panelid == displayedPanelId()}
nextPassages={store}
lines={_lines}
/>
);
newPanels.push(panel);
positioneds.push({ position: panelid, panel });
chunk = {};
chunk[lineId] = byLinePassages;
chunkSize = byLinePassagesKeys.length;
}
}
if (chunkSize) {
const panelId = index++;
const [store, setStore] = createStore(chunk);
const panel = (
<NextPassagesPanel
show={panelId == displayedPanelId()}
nextPassages={store}
lines={_lines}
/>
);
newPanels.push(panel);
positioneds.push({ position: panelId, panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
{_computeFooter()}
</div>
);
};

View File

@@ -0,0 +1,121 @@
import { Component } from 'solid-js';
import { createStore } from 'solid-js/store';
import { createDateNow, getTime } from '@solid-primitives/date';
import { Motion } from "@motionone/solid";
import { TrafficStatus } from './types';
import { renderLineTransportMode, renderLinePicto } from './utils';
import styles from './nextPassagesDisplay.module.css';
export const NextPassagesPanel: Component = (props) => {
/* TODO: Find where to get data to compute traffic status. */
const trafficStatusColor = new Map<TrafficStatus, string>([
[TrafficStatus.UNKNOWN, "#ffffff"],
[TrafficStatus.FLUID, "#00643c"],
[TrafficStatus.DISRUPTED, "#ffbe00"],
[TrafficStatus.VERY_DISRUPTED, "#ff5a00"],
[TrafficStatus.BYPASSED, "#ffffff"]
]);
const [dateNow] = createDateNow(5000);
function _computeTtwPassage(class_, passage, fontSize) {
const refTs = passage.expectedDepartTs !== null ? passage.expectedDepartTs : passage.expectedArrivalTs;
const ttwSec = refTs - (getTime(dateNow()) / 1000);
const isApproaching = ttwSec <= 60;
return (
<div class={class_}>
<svg viewBox={`0 0 215 ${fontSize}`}>
<Motion.text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={fontSize} style={{ fill: "#000000" }}
initial={isApproaching}
animate={{ opacity: [1, 0, 1] }}
transition={{ duration: 3, repeat: Infinity }}>
{Math.floor(ttwSec / 60)} min
</Motion.text>
</svg>
</div>
);
}
function _computeUnavailablePassage(class_) {
const textStyle = { fill: "#000000" };
return (
<div class={class_}>
<svg viewbox="0 0 230 110">
<text x="100%" y="26" font-size="25" text-anchor="end" style={textStyle}>Information</text>
<text x="100%" y="63" font-size="25" text-anchor="end" style={textStyle}>non</text>
<text x="100%" y="100" font-size="25" text-anchor="end" style={textStyle}>disponible</text>
</svg>
</div>
);
}
function _computeSecondPassage(passage): JSX.Element {
return (
<Show when={passage !== undefined} fallback={_computeUnavailablePassage(styles.unavailableSecondPassage)}>
{_computeTtwPassage(styles.secondPassage, passage, 45)}
</Show>
);
}
function _computeFirstPassage(passage): JSX.Element {
return (
<Show when={passage !== undefined} fallback={_computeUnavailablePassage(styles.unavailableFirstPassage)}>
{_computeTtwPassage(styles.firstPassage, passage, 50)}
</Show>
);
}
/* TODO: Manage end of service */
function _genNextPassages(nextPassages, line, destination) {
const nextPassagesLength = nextPassages.length;
const firstPassage = nextPassagesLength > 0 ? nextPassages[0] : undefined;
const secondPassage = nextPassagesLength > 1 ? nextPassages[1] : undefined;
const trafficStatusStyle = { fill: trafficStatusColor.get(line.trafficStatus) };
return (
<div class={styles.line}>
<div class={styles.transportMode}>
{renderLineTransportMode(line)}
</div>
{renderLinePicto(line, styles)}
<div class={styles.destination}>
<svg viewbox="0 0 600 40">
<text x="0" y="50%" dominant-baseline="middle" font-size="40" style={{ fill: "#000000" }}>
{destination}
</text>
</svg>
</div>
<div class={styles.trafficStatus}>
<svg viewBox="0 0 51 51">
<circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} />
</svg>
</div>
{firstPassage ? _computeFirstPassage(firstPassage) : null}
{secondPassage ? _computeSecondPassage(secondPassage) : null}
</div>
);
}
return (
<div classList={{ [styles.nextPassagesContainer]: true, [styles.displayed]: props.show }} style={{ "top": `${100 * props.position}%` }}>
{() => {
const ret = [];
for (const lineId of Object.keys(props.nextPassages)) {
const line = props.lines.get(lineId);
const byLineNextPassages = props.nextPassages[lineId];
for (const destination of Object.keys(byLineNextPassages)) {
const nextPassages = byLineNextPassages[destination];
ret.push(_genNextPassages(nextPassages, line, destination));
}
}
return ret;
}}
</div>
);
}

75
frontend/src/search.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { batch, createContext, createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Stop, Stops } from './types';
interface Store {
getMarkers: () => Markers;
addMarkers?: (stopId, markers) => void;
setMarkers?: (markers) => void;
getStops: () => Stops;
setStops?: (stops) => void;
removeStops?: (stopIds) => void;
getDisplayedStop: () => Stop;
setDisplayedStop: (stop: Stop) => void;
};
export const SearchContext = createContext<Store>();
export function SearchProvider(props: { children: JSX.Element }) {
const [store, setStore] = createStore({stops: {}, markers: {}, displayedStop: []});
const getStops = () => {
return store.stops;
};
const setStops = (stops) => {
setStore((s) => {
setStore('stops', stops);
});
};
const removeStops = (stopIds) => {
batch(() => {
for(const stopId of stopIds) {
setStore('stops', stopId, undefined);
setStore('markers', stopId, undefined);
}
});
};
const getMarkers = () => {
return store.markers;
};
const addMarkers = (stopId, markers) => {
setStore('markers', stopId, markers);
};
const setMarkers = (markers) => {
setStore('markers', markers);
};
const getDisplayedStop = () => {
/* console.log(store.displayedStop); */
return store.displayedStop;
};
const setDisplayedStop = (stop: Stop) => {
/* console.log(stop); */
setStore((s) => {
console.log("s.displayedStop=", s.displayedStop);
setStore('displayedStop', [stop]);
});
/* console.log(store.displayedStop); */
};
return (
<SearchContext.Provider value={{addMarkers, getMarkers, setMarkers, getStops, removeStops, setStops, getDisplayedStop, setDisplayedStop}}>
{props.children}
</SearchContext.Provider>
);
}

View File

@@ -0,0 +1,33 @@
svg {
font-family: IDFVoyageur-bold;
}
.transportMode {
aspect-ratio : 1 / 1;
height: 70%;
margin-left: 1%;
}
.tramLinePicto {
height: 70%;
margin-left: 1%;
aspect-ratio : 1 / 1;
}
.trainLinePicto {
height: 70%;
margin-left: 1%;
aspect-ratio : 1 / 1;
}
.metroLinePicto {
height: 70%;
margin-left: 1%;
aspect-ratio : 1 / 1;
}
.busLinePicto {
height: 70%;
margin-left: 1%;
aspect-ratio : 2.25;
}

View File

@@ -0,0 +1,224 @@
import { batch, Component, createEffect, createResource, createSignal, onMount, Show, useContext } from 'solid-js';
import {
Box, Button, Input, InputLeftAddon, InputGroup, HStack, List, ListItem, Progress,
ProgressIndicator, VStack
} from "@hope-ui/solid";
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
import { BusinessDataContext } from './businessData';
import { SearchContext } from './search';
import { renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils';
import styles from './stopManager.module.css';
const StopRepr: Component = (props) => {
const { getLine } = useContext(BusinessDataContext);
const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr);
async function fetchLinesRepr(lineIds) {
const reprs = [];
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
reprs.push(<div class={styles.transportMode}>{renderLineTransportMode(line)}</div>);
reprs.push(renderLinePicto(line, styles));
}
}
return reprs;
}
return (
<HStack height="100%">
{props.stop.name}
<For each={lineReprs()}>{(line) => line}</For>
</HStack>
);
}
const StopAreaRepr: Component = (props) => {
const { getLine } = useContext(BusinessDataContext);
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
async function fetchLinesRepr(stop) {
const lineIds = new Set(stop.lines);
const stops = stop.stops;
for (const stop of stops) {
stop.lines.forEach(lineIds.add, lineIds);
}
const byModeReprs = {};
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
if (!(line.transportMode in byModeReprs)) {
byModeReprs[line.transportMode] = {
mode: <div class={styles.transportMode}>{renderLineTransportMode(line)}</div>
};
}
byModeReprs[line.transportMode][line.shortName] = renderLinePicto(line, styles);
}
}
const reprs = [];
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < TransportModeWeights[y]);
for (const transportMode of sortedTransportModes) {
const lines = byModeReprs[transportMode];
const repr = [lines.mode];
delete lines.mode;
for (const lineId of Object.keys(lines).sort((x, y) => x.localeCompare(y))) {
repr.push(lines[lineId]);
}
reprs.push(repr);
}
return reprs;
}
return (
<HStack height="100%">
{props.stop.name}
<For each={lineReprs()}>{(line) => line}</For>
</HStack>
);
}
const Map: Component = (props) => {
const mapCenter = [48.853, 2.35];
const { addMarkers, getStops } = useContext(SearchContext);
let mapDiv: any;
let map = null;
const stopsLayerGroup = L.featureGroup();
function buildMap(div: HTMLDivElement) {
map = L.map(div).setView(mapCenter, 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
stopsLayerGroup.addTo(map);
}
function setMarker(stop) {
const markers = [];
if (stop.lat !== undefined && stop.lon !== undefined) {
/* TODO: Add stop lines representation to popup. */
markers.push(L.marker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup());
}
else {
for (const _stop of stop.stops) {
markers.push(...setMarker(_stop));
}
}
return markers;
}
onMount(() => buildMap(mapDiv));
const onStopUpdate = createEffect(() => {
/* TODO: Avoid to clear all layers... */
stopsLayerGroup.clearLayers();
for (const stop of Object.values(getStops())) {
const markers = setMarker(stop);
addMarkers(stop.id, markers);
for (const marker of markers) {
stopsLayerGroup.addLayer(marker);
}
}
const stopsBound = stopsLayerGroup.getBounds();
if (Object.keys(stopsBound).length) {
map.fitBounds(stopsBound);
}
});
return <div ref={mapDiv} id='main-map' style={{ width: "100%", height: "100%" }} />;
}
export const StopsManager: Component = (props) => {
const [minCharactersNb, setMinCharactersNb] = createSignal<int>(4);
const [_inProgress, _setInProgress] = createSignal<bool>(false);
const { serverUrl } = useContext(BusinessDataContext);
const { getStops, removeStops, setStops, setDisplayedStop } = useContext(SearchContext);
async function _fetchStopByName(name) {
const data = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' }
});
const stops = await data.json();
const stopIds = stops.map((stop) => stop.id);
const stopIdsToRemove = Object.keys(getStops()).filter(stopId => !(stopId in stopIds));
const byIdStops = {};
for (const stop of stops) {
byIdStops[stop.id] = stop;
}
batch(() => {
removeStops(stopIdsToRemove);
setStops(byIdStops);
});
}
async function _onStopNameInput(event) {
/* TODO: Add a tempo before fetching stop for giving time to user to finish his request */
const stopName = event.target.value;
if (stopName.length >= minCharactersNb()) {
console.log(`Fetching data for ${stopName}`);
_setInProgress(true);
await _fetchStopByName(stopName);
_setInProgress(false);
}
}
return (
<VStack h="100%">
<InputGroup w="50%" h="5%">
<InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={_onStopNameInput} readOnly={_inProgress()} placeholder="Stop name..." />
</InputGroup>
<Progress size="xs" w="50%" indeterminate={_inProgress()}>
<ProgressIndicator striped animated />
</Progress>
<Box w="100%" h="40%" borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" overflow="scroll" marginBottom="2px">
<List width="100%" height="100%">
{() => {
const items = [];
for (const stop of Object.values(getStops()).sort((x, y) => x.name.localeCompare(y.name))) {
items.push(
<ListItem h="10%" borderWidth="1px" mb="0px" color="var(--idfm-black)" borderRadius="$lg">
<Button fullWidth="true" color="var(--idfm-black)" bg="white" onClick={() => {
console.log(`${stop.id} clicked !!!`);
setDisplayedStop(stop);
}}>
<Box w="100%" h="100%">
<Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
<StopAreaRepr stop={stop} />
</Show>
</Box>
</Button>
</ListItem>);
}
return items;
}}
</List>
</Box>
<Box borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" h="55%" w="100%" overflow="scroll">
<Map />
</Box>
</VStack>
);
};

43
frontend/src/types.tsx Normal file
View File

@@ -0,0 +1,43 @@
const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"];
export function getTransportModeSrc(mode: string, color: bool = true): string {
let ret = null;
if (validTransportModes.includes(mode)) {
ret = `/public/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
}
return ret;
}
export enum TrafficStatus {
UNKNOWN = 0,
FLUID,
DISRUPTED,
VERY_DISRUPTED,
BYPASSED
}
export interface Passages { };
export interface Passage {
line: number,
operator: string,
destinations: Array<string>,
atStop: boolean,
aimedArrivalTs: number,
expectedArrivalTs: number,
arrivalPlatformName: string,
aimedDepartTs: number,
expectedDepartTs: number,
arrivalStatus: string,
departStatus: string,
};
export interface Stops { };
export interface Stop {
id: number,
name: string,
town: string,
lat: number,
lon: number,
lines: Array<string>
};

106
frontend/src/utils.tsx Normal file
View File

@@ -0,0 +1,106 @@
import { getTransportModeSrc } from './types';
export const TransportModeWeights = {
bus: 1,
tram: 2,
val: 3,
funicular: 4,
metro: 5,
rer: 6,
transilien: 7,
ter: 8,
};
export function renderLineTransportMode(line): JSX.Element {
return <img src={getTransportModeSrc(line.transportMode)} />
}
function renderBusLinePicto(line, styles): JSX.Element {
return (
<div class={styles.busLinePicto}>
<svg viewBox="0 0 31.5 14">
<rect x="0" y="0" width="31.5" height="14" rx="1.5" ry="1.5" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="7.4"
style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
}
function renderTramLinePicto(line, styles): JSX.Element {
const lineStyle = { fill: `#${line.backColorHexa}` };
return (
<div class={styles.tramLinePicto}>
<svg viewBox="0 0 20 20">
<rect x="0" y="0" width="20" height="3" rx="1" ry="1" style={lineStyle} />
<rect x="0" y="17" width="20" height="3" rx="1" ry="1" style={lineStyle} />
<text x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11"
style={{ fill: "#00000" }}>
{line.shortName}
</text>
</svg>
</div>
);
}
function renderMetroLinePicto(line, styles): JSX.Element {
return (
<div class={styles.metroLinePicto}>
<svg viewbox="0 0 20 20">
<circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11" style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
}
function renderTrainLinePicto(line, styles): JSX.Element {
return (
<div class={styles.trainLinePicto}>
<svg viewbox="0 0 20 20">
<rect x="0" y="0" width="20" height="20" rx="4.5" ry="4.5" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="11"
style={{ fill: `#${line.foreColorHexa}` }}>
{line.shortName}
</text>
</svg>
</div>
);
}
export function renderLinePicto(line, styles): JSX.Element {
switch (line.transportMode) {
case "bus":
case "funicular":
return renderBusLinePicto(line, styles);
case "tram":
return renderTramLinePicto(line, styles);
/* case "val": */
case "metro":
return renderMetroLinePicto(line, styles);
case "transilien":
case "rer":
case "ter":
return renderTrainLinePicto(line, styles);
}
}