diff --git a/README.md b/README.md index 8a2cbfd..dbfa2fe 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,11 @@ - Modèle 3D des véhicules () * Simulation - Simulation côté serveur - - Montrer l’itinéraire emprunté habituellement par une ligne vs. l’itinéraire emprunté dans les 24 dernières heures - Retour dans le passé (historique de la simulation côté serveur) + - Améliorer réalisme de la simulation + - Accélération/décéleration + - Signaux de rail (un seul véhicule par segment, priorités) + - Montrer l’itinéraire emprunté habituellement par une ligne vs. l’itinéraire emprunté dans les 24 dernières heures - Statistiques ? (retards, vitesse moyenne, ...) * Données - Intégrer vélopartage (Vélomagg, Bixi) diff --git a/src/front/index.js b/src/front/index.js index 2c5f17d..4db8f68 100644 --- a/src/front/index.js +++ b/src/front/index.js @@ -1,141 +1,50 @@ -import network from "../data/network.json"; import * as simulation from "../data/simulation.js"; import Map from "./map/index.js"; +import { updatePanel } from "./panel.js"; -// Run courses simulation -const simstate = simulation.start(); +const mapKey = "6T8Cb9oYBFeDjDhpmNmd"; +const bounds = [ + "3.660507202148437", "43.49552248630757", + "4.092750549316406", "43.73736766145917", +]; + +const map = new Map("map", bounds, mapKey); +window.__map = map; -// Create display panel const panel = document.querySelector("#panel"); -const timeFormat = new Intl.DateTimeFormat("fr-FR", { - timeZone: "Europe/Paris", - timeStyle: "medium", -}); +map.addEventListener("ready", () => { + const simstate = simulation.start(); + window.__simstate = simstate.courses; -const timeToHTML = time => { - const delta = Math.ceil((time - Date.now()) / 1000); + let showCourse = null; - if (delta <= 0) { - return `Imminent`; - } else if (delta < 60) { - return `${delta} s`; - } else { - return `${Math.floor(delta / 60)} min ${delta % 60} s`; - } -}; + const boundUpdatePanel = () => { + updatePanel(panel, simstate.courses, showCourse); + }; -const updatePanel = () => { - const vehicleCount = Object.values(simstate.courses).length; - const movingVehicles = Object.values(simstate.courses).filter( - course => course.properties.speed > 0 - ).length; + const animateCourses = () => { + simstate.update(); + map.update(simstate.courses); + requestAnimationFrame(animateCourses); + }; - let html = ` -
-
Heure locale
-
${timeFormat.format(Date.now())}
-
-
-
Véhicules sur la carte
-
- ${movingVehicles} en mouvement, - ${vehicleCount - movingVehicles} à l’arrêt -
-
- `; - - if (map.selectedCourse !== null && map.selectedCourse in simstate.courses) { - const course = simstate.courses[map.selectedCourse].properties; - - const stopToHTML = stopId => stopId in network.stops ? - network.stops[stopId].properties.name : - 'Arrêt inconnu'; - - const passingsToHTML = passings => passings.map(([stopId, time]) => ` - - ${stopToHTML(stopId)} - ${timeFormat.format(time)} - - `).join("\n"); - - const state = ( - course.traveledDistance === 0 && course.speed === 0 - ? "stopped" : "moving" - ); - - let prevPassings = course.prevPassings; - - if (state === "moving") { - prevPassings = prevPassings.concat([[course.departureStop, course.departureTime]]); - } - - html += ` -

Véhicule

-
-
ID
-
${map.selectedCourse}
- -
Ligne
-
${course.line}
- -
Destination
-
${stopToHTML(course.finalStop)}
- -
État
-
${state === "moving" - ? `Entre ${stopToHTML(course.departureStop)} - et ${stopToHTML(course.arrivalStop)}` - : `À l’arrêt ${stopToHTML(course.departureStop)}`}
- - ${state === "moving" ? ` -
Arrivée dans
-
${timeToHTML(course.arrivalTime - 10000)}
- -
Distance parcourue
-
${Math.ceil(course.traveledDistance)} m
- -
Vitesse
-
${Math.ceil(course.speed * 3600)} km/h
- ` : ` -
Départ dans
-
${timeToHTML(course.departureTime)}
- `} -
- -

Arrêts précédents

- ${passingsToHTML(prevPassings)}
- -

Arrêts suivants

- ${passingsToHTML(course.nextPassings)}
- `; - } - - panel.innerHTML = html; -}; - -setInterval(updatePanel, 1000); - -const map = new Map("map", simstate, { - onVehicleClick(courses) { - courses = [...courses]; - courses.sort(); - - const index = courses.indexOf(map.selectedCourse); + map.addEventListener("click-courses", event => { + const courses = event.detail; + const index = courses.indexOf(showCourse); if (courses.length === 0) { - map.select(null); + showCourse = null; } else if (index === -1) { - map.select(courses[0]); + showCourse = courses[0]; } else { - map.select(courses[(index + 1) % courses.length]); + showCourse = courses[(index + 1) % courses.length]; } - updatePanel(); - } -}); + map.follow(showCourse); + boundUpdatePanel(); + }); -// Reference as globals to facilitate debugging -window.__map = map; -window.__simstate = simstate.courses; -window.__network = network; + setInterval(boundUpdatePanel, 1000); + animateCourses(); +}); diff --git a/src/front/map/index.js b/src/front/map/index.js index e152e70..69338e0 100644 --- a/src/front/map/index.js +++ b/src/front/map/index.js @@ -5,92 +5,99 @@ import * as network from "./network"; import * as vehicles from "./vehicles"; import style from "./assets/style.json"; -const mapId = "c1309cde-1b6e-4c9b-8b65-48512757d354"; -const mapKey = "6T8Cb9oYBFeDjDhpmNmd"; - -style.sources.openmaptiles.url = style.sources.openmaptiles.url.replace("{key}", mapKey); -style.glyphs = style.glyphs.replace("{key}", mapKey); - -const bounds = [ - "3.660507202148437", "43.49552248630757", - "4.092750549316406", "43.73736766145917", -]; - -export default class Map { +export default class Map extends EventTarget { /** * Instantiate a map. - * @param {string|HTMLElement} target The container to add the map to. - * @param {Object} simstate Course simulation state. - * @param {Object.} handlers Handlers for the map events. + * @param {string|HTMLElement} target HTML container to add the map to. + * @param {Array} bounds Initial map bounds. + * @param {string} apiKey MapTiler API key. */ - constructor(target, simstate, handlers) { - this.renderer = new maplibre.Map({ - container: target, - style, - bounds, + constructor(target, bounds, apiKey) { + super(); + // Replace map key placeholder in style definition + const thisStyle = JSON.parse(JSON.stringify(style)); + thisStyle.sources.openmaptiles.url = ( + style.sources.openmaptiles.url.replace("{key}", apiKey) + ); + thisStyle.glyphs = style.glyphs.replace("{key}", apiKey); + + // Initialize MapLibre renderer + this._renderer = new maplibre.Map({ + container: target, + bounds, + style: thisStyle, maplibreLogo: true, maxPitch: 70, }); - this.renderer.addControl(new maplibre.NavigationControl()); - - this.renderer.on("load", () => { - network.addLayers(this.renderer); - vehicles.addLayers(this.renderer, handlers); + this._renderer.on("load", () => { + network.addLayers(this._renderer); + vehicles.addLayers(this._renderer, courses => { + this.dispatchEvent(new CustomEvent( + "click-courses", + {detail: courses} + )); + }); // Move 3D buildings to the front of custom layers - this.renderer.moveLayer("building-3d"); - this.start(); + this._renderer.moveLayer("building-3d"); + + this.dispatchEvent(new CustomEvent("ready")); }); - // ID of the currently selected course - this.selectedCourse = null; + this._renderer.addControl(new maplibre.NavigationControl()); - this.animation = null; - this.simstate = simstate; - this.handlers = handlers; + // Last known courses state + this._courses = null; + + // ID of a course to follow + this._follow = null; } - /** Start animating vehicles on the map. */ - start() { - this._animate(); - } - - /** Stop the vehicle animation. */ - stop() { - cancelAnimationFrame(this.animation); - this.animation = null; + /** Stop following a course. */ + unfollow() { + this._follow = null; } /** - * Select or unselect a vehicle. - * @param {string?} id The ID of the vehicle to select, or null to unselect. + * Start following a course with the camera. + * @param {string} courseId The ID of the course to follow. */ - select(courseId) { - this.selectedCourse = courseId; + follow(courseId) { + if (courseId in this._courses) { + this._follow = courseId; - const course = this.simstate.courses[courseId]; - this.renderer.flyTo({ - center: course.geometry.coordinates, - bearing: course.properties.bearing, - pitch: 60, - zoom: 20, - }); - } - - _animate() { - this.simstate.update(); - vehicles.update(this.renderer, this.simstate.courses); - - if (this.selectedCourse !== null && !this.renderer.isMoving()) { - const course = this.simstate.courses[this.selectedCourse]; - this.renderer.jumpTo({ + const course = this._courses[courseId]; + this._renderer.flyTo({ center: course.geometry.coordinates, bearing: course.properties.bearing, + pitch: 60, + zoom: 20, }); + } else { + this._follow = null; } + } - this.animation = requestAnimationFrame(this._animate.bind(this)); + /** Update the map with new courses state. */ + update(courses) { + this._courses = courses; + vehicles.update(this._renderer, courses); + + if (this._follow !== null) { + if (this._follow in courses) { + const course = courses[this._follow]; + + if (course.properties.speed > 0) { + this._renderer.jumpTo({ + center: course.geometry.coordinates, + bearing: course.properties.bearing, + }); + } + } else { + this._follow = null; + } + } } } diff --git a/src/front/map/vehicles.js b/src/front/map/vehicles.js index bc65aeb..a4f5515 100644 --- a/src/front/map/vehicles.js +++ b/src/front/map/vehicles.js @@ -40,9 +40,9 @@ const makeVehicleIcon = size => { /** * Add layers that display the live vehicle positions on the map. * @param {Map} map The map to add the layers to. - * @param {Object.} handlers Handlers for the map events. + * @param {Function} onClick Function to call when a vehicle is clicked. */ -export const addLayers = (map, handlers) => { +export const addLayers = (map, onClick) => { map.addImage("vehicle", makeVehicleIcon(128), {sdf: true}); map.addSource("vehicles", { @@ -63,6 +63,8 @@ export const addLayers = (map, handlers) => { "icon-image": "vehicle", "icon-size": scaleZoom(factor), "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-overlap": "always", "icon-rotation-alignment": "map", "icon-rotate": ["get", "bearing"], }, @@ -89,7 +91,7 @@ export const addLayers = (map, handlers) => { return time1 - time2; }); - handlers.onVehicleClick(event.features.map( + onClick(event.features.map( feature => feature.properties.id )); }); diff --git a/src/front/panel.js b/src/front/panel.js new file mode 100644 index 0000000..f859b44 --- /dev/null +++ b/src/front/panel.js @@ -0,0 +1,116 @@ +import network from "../data/network.json"; + +const timeFormat = new Intl.DateTimeFormat("fr-FR", { + timeZone: "Europe/Paris", + timeStyle: "medium", +}); + +const timeToHTML = time => { + const delta = Math.ceil((time - Date.now()) / 1000); + + if (delta <= 0) { + return `Imminent`; + } else if (delta < 60) { + return `${delta} s`; + } else { + return `${Math.floor(delta / 60)} min ${delta % 60} s`; + } +}; + +/** + * Update the contents of the side panel with course information. + * @param {HTMLElement} target The panel element. + * @param {Object} courses Active courses. + * @param {string?} showCourse The ID of a course to show. + */ +export const updatePanel = (target, courses, showCourse) => { + const vehicleCount = Object.values(courses).length; + const movingVehicles = Object.values(courses).filter( + course => course.properties.speed > 0 + ).length; + + let html = ` +
+
Heure locale
+
${timeFormat.format(Date.now())}
+
+
+
Véhicules sur la carte
+
+ ${movingVehicles} en mouvement, + ${vehicleCount - movingVehicles} à l’arrêt +
+
+ `; + + if (showCourse !== null && showCourse in courses) { + const course = courses[showCourse].properties; + + const stopToHTML = stopId => stopId in network.stops ? + network.stops[stopId].properties.name : + 'Arrêt inconnu'; + + const passingsToHTML = passings => passings.map(([stopId, time]) => ` + + ${stopToHTML(stopId)} + ${timeFormat.format(time)} + + `).join("\n"); + + const state = ( + course.traveledDistance === 0 && course.speed === 0 + ? "stopped" : "moving" + ); + + let prevPassings = course.prevPassings; + + if (state === "moving") { + prevPassings = prevPassings.concat([[ + course.departureStop, + course.departureTime + ]]); + } + + html += ` +

Véhicule

+
+
ID
+
${showCourse}
+ +
Ligne
+
${course.line}
+ +
Destination
+
${stopToHTML(course.finalStop)}
+ +
État
+
${state === "moving" + ? `Entre ${stopToHTML(course.departureStop)} + et ${stopToHTML(course.arrivalStop)}` + : `À l’arrêt ${stopToHTML(course.departureStop)}`}
+ + ${state === "moving" ? ` +
Arrivée dans
+
${timeToHTML(course.arrivalTime - 10000)}
+ +
Distance parcourue
+
${Math.ceil(course.traveledDistance)} m
+ +
Vitesse
+
${Math.ceil(course.speed * 3600)} km/h
+ ` : ` +
Départ dans
+
${timeToHTML(course.departureTime)}
+ `} +
+ +

Arrêts précédents

+ ${passingsToHTML(prevPassings)}
+ +

Arrêts suivants

+ ${passingsToHTML(course.nextPassings)}
+ `; + } + + target.innerHTML = html; +};