🎉 First commit !!!
This commit is contained in:
21
frontend/src/App.module.css
Normal file
21
frontend/src/App.module.css
Normal 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
65
frontend/src/App.tsx
Normal 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;
|
BIN
frontend/src/assets/favicon.ico
Normal file
BIN
frontend/src/assets/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
79
frontend/src/businessData.tsx
Normal file
79
frontend/src/businessData.tsx
Normal 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
28
frontend/src/index.css
Normal 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
7
frontend/src/index.tsx
Normal 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);
|
226
frontend/src/nextPassagesDisplay.module.css
Normal file
226
frontend/src/nextPassagesDisplay.module.css
Normal 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%);
|
||||
}
|
253
frontend/src/nextPassagesDisplay.tsx
Normal file
253
frontend/src/nextPassagesDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
121
frontend/src/nextPassagesPanel.tsx
Normal file
121
frontend/src/nextPassagesPanel.tsx
Normal 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
75
frontend/src/search.tsx
Normal 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>
|
||||
);
|
||||
}
|
33
frontend/src/stopManager.module.css
Normal file
33
frontend/src/stopManager.module.css
Normal 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;
|
||||
}
|
224
frontend/src/stopsManager.tsx
Normal file
224
frontend/src/stopsManager.tsx
Normal 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: '© <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
43
frontend/src/types.tsx
Normal 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
106
frontend/src/utils.tsx
Normal 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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user