From 9e41bf8079542c4706db95f071752df0dc43dac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matt=C3=A9o=20Delabre?= Date: Sat, 9 Jul 2022 18:14:00 -0400 Subject: [PATCH] Implement clicking on course & follow mode --- src/front/index.js | 54 +++++++----- src/front/map/index.js | 96 +++++++++++++++----- src/front/map/vehicles.js | 178 ++++++++------------------------------ 3 files changed, 139 insertions(+), 189 deletions(-) diff --git a/src/front/index.js b/src/front/index.js index 1a555cc..2c5f17d 100644 --- a/src/front/index.js +++ b/src/front/index.js @@ -1,13 +1,9 @@ import network from "../data/network.json"; import * as simulation from "../data/simulation.js"; -import * as map from "./map/index.js"; +import Map from "./map/index.js"; // Run courses simulation -const coursesSimulation = simulation.start(); -let selectedCourse = null; - -window.__courses = coursesSimulation.courses; -window.__network = network; +const simstate = simulation.start(); // Create display panel const panel = document.querySelector("#panel"); @@ -29,9 +25,9 @@ const timeToHTML = time => { } }; -setInterval(() => { - const vehicleCount = Object.values(coursesSimulation.courses).length; - const movingVehicles = Object.values(coursesSimulation.courses).filter( +const updatePanel = () => { + const vehicleCount = Object.values(simstate.courses).length; + const movingVehicles = Object.values(simstate.courses).filter( course => course.properties.speed > 0 ).length; @@ -49,8 +45,8 @@ setInterval(() => { `; - if (selectedCourse !== null && selectedCourse in coursesSimulation.courses) { - const course = coursesSimulation.courses[selectedCourse]; + 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 : @@ -75,9 +71,10 @@ setInterval(() => { } html += ` +

Véhicule

ID
-
${selectedCourse}
+
${map.selectedCourse}
Ligne
${course.line}
@@ -115,17 +112,30 @@ setInterval(() => { } panel.innerHTML = html; -}, 1000); +}; -// Create the network and courses map -window.__map = map.create(/* map = */ "map", coursesSimulation, courses => { - const index = courses.indexOf(selectedCourse); +setInterval(updatePanel, 1000); - if (courses.length === 0) { - selectedCourse = null; - } else if (index === -1) { - selectedCourse = courses[0]; - } else { - selectedCourse = courses[(index + 1) % courses.length]; +const map = new Map("map", simstate, { + onVehicleClick(courses) { + courses = [...courses]; + courses.sort(); + + const index = courses.indexOf(map.selectedCourse); + + if (courses.length === 0) { + map.select(null); + } else if (index === -1) { + map.select(courses[0]); + } else { + map.select(courses[(index + 1) % courses.length]); + } + + updatePanel(); } }); + +// Reference as globals to facilitate debugging +window.__map = map; +window.__simstate = simstate.courses; +window.__network = network; diff --git a/src/front/map/index.js b/src/front/map/index.js index d67413e..e152e70 100644 --- a/src/front/map/index.js +++ b/src/front/map/index.js @@ -1,5 +1,5 @@ import "maplibre-gl/dist/maplibre-gl.css"; -import { Map, NavigationControl } from "maplibre-gl"; +import * as maplibre from "maplibre-gl"; import * as network from "./network"; import * as vehicles from "./vehicles"; @@ -16,33 +16,81 @@ const bounds = [ "4.092750549316406", "43.73736766145917", ]; -export const create = (target, simulation, onClick) => { - const map = new Map({ - container: target, - style, - bounds, +export default class Map { + /** + * 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. + */ + constructor(target, simstate, handlers) { + this.renderer = new maplibre.Map({ + container: target, + style, + bounds, - maplibreLogo: true, - maxPitch: 70, - }); + maplibreLogo: true, + maxPitch: 70, + }); - map.addControl(new NavigationControl()); + this.renderer.addControl(new maplibre.NavigationControl()); - const animate = () => { - simulation.update(); - vehicles.update(map, simulation.courses); - requestAnimationFrame(animate); - }; + this.renderer.on("load", () => { + network.addLayers(this.renderer); + vehicles.addLayers(this.renderer, handlers); - map.on("load", () => { - network.addLayers(map); - vehicles.addLayers(map); + // Move 3D buildings to the front of custom layers + this.renderer.moveLayer("building-3d"); + this.start(); + }); - // Move 3D buildings to the front of custom layers - map.moveLayer("building-3d"); + // ID of the currently selected course + this.selectedCourse = null; - animate(); - }); + this.animation = null; + this.simstate = simstate; + this.handlers = handlers; + } - return map; -}; + /** Start animating vehicles on the map. */ + start() { + this._animate(); + } + + /** Stop the vehicle animation. */ + stop() { + cancelAnimationFrame(this.animation); + this.animation = null; + } + + /** + * Select or unselect a vehicle. + * @param {string?} id The ID of the vehicle to select, or null to unselect. + */ + select(courseId) { + this.selectedCourse = 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({ + center: course.geometry.coordinates, + bearing: course.properties.bearing, + }); + } + + this.animation = requestAnimationFrame(this._animate.bind(this)); + } +} diff --git a/src/front/map/vehicles.js b/src/front/map/vehicles.js index 12bc260..bc65aeb 100644 --- a/src/front/map/vehicles.js +++ b/src/front/map/vehicles.js @@ -1,15 +1,8 @@ -// import { getVectorContext } from "ol/render"; -// import Point from "ol/geom/Point"; -// import { fromExtent } from 'ol/geom/Polygon'; -// import { Style, Icon } from "ol/style"; import * as turfHelpers from "@turf/helpers"; import { scaleZoom, makeBorderColor, makeCourseColor } from "./common"; import network from "../../data/network.json"; -const VEHICLE_SIZE = 15; -const VEHICLE_BORDER = 10; - const makeVehicleIcon = size => { const canvas = window.document.createElement("canvas"); const context = canvas.getContext("2d"); @@ -20,8 +13,8 @@ const makeVehicleIcon = size => { const cx = size / 2; const cy = size / 2; - const iconSize = 0.4 * size; - const borderSize = 0.2 * size; + const iconSize = 0.7 * size; + const borderSize = 0.3 * size; context.fillStyle = "black"; context.strokeStyle = "black"; @@ -47,8 +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. */ -export const addLayers = map => { +export const addLayers = (map, handlers) => { map.addImage("vehicle", makeVehicleIcon(128), {sdf: true}); map.addSource("vehicles", { @@ -77,6 +71,37 @@ export const addLayers = map => { }, }); } + + map.on("click", "vehicles-outer", event => { + // Sort clicked courses in increasing order of departing/arrival time + event.features.sort((feature1, feature2) => { + const course1 = feature1.properties; + const course2 = feature2.properties; + + const time1 = ( + course1.speed > 0 ? course1.arrivalTime : course1.departureTime + ); + + const time2 = ( + course2.speed > 0 ? course2.arrivalTime : course2.departureTime + ); + + return time1 - time2; + }); + + handlers.onVehicleClick(event.features.map( + feature => feature.properties.id + )); + }); + + // Show a pointer cursor when hovering over a vehicle + map.on("mouseenter", "vehicles-outer", () => { + map.getCanvas().style.cursor = "pointer"; + }); + + map.on("mouseleave", "vehicles-outer", () => { + map.getCanvas().style.cursor = ''; + }); }; /** @@ -98,136 +123,3 @@ export const update = (map, courses) => { turfHelpers.featureCollection(Object.values(courses)) ); }; - -// const courseStyle = cacheStyle( -// lineColor => { -// const icon = window.document.createElement("canvas"); - -// const shapeSize = sizes.courseSize; -// const iconSize = sizes.courseSize + sizes.courseOuterBorder; - -// icon.width = iconSize; -// icon.height = iconSize; - -// const cx = icon.width / 2; -// const cy = icon.height / 2; - -// const iconCtx = icon.getContext("2d"); - -// for (const [color, size] of [ -// [makeBorderColor(lineColor), sizes.courseOuterBorder], -// [lineColor, sizes.courseBorder], -// [makeCourseColor(lineColor), sizes.courseInnerBorder] -// ]) { -// iconCtx.fillStyle = color; -// iconCtx.strokeStyle = color; -// iconCtx.lineWidth = size; -// iconCtx.lineJoin = "round"; -// iconCtx.miterLimit = 200000; - -// iconCtx.beginPath(); -// iconCtx.moveTo(cx - 0.5 * shapeSize, cy - 0.3 * shapeSize); -// iconCtx.lineTo(cx + 0.5 * shapeSize, cy); -// iconCtx.lineTo(cx - 0.5 * shapeSize, cy + 0.3 * shapeSize); -// iconCtx.closePath(); -// iconCtx.stroke(); -// iconCtx.fill(); -// } - -// return new Style({ -// image: new Icon({ -// img: icon, -// imgSize: [icon.width, icon.height] -// }) -// }); -// }, -// ); - -// export const setupCoursesAnimation = ( -// map, coursesSimulation, stopsLayer, onClick -// ) => { -// const view = map.getView(); - -// // Draw courses directly on the map -// map.on("postcompose", ev => { -// coursesSimulation.update(); - -// // The normal way to access a layer’s vector context is through the -// // `postrender` event of that layer. However, `postrender` is not -// // triggered when no feature of the layer is inside the current -// // bounding box, but we want to draw vehicles in between stops even -// // if no stop is visible. This hack listens to the global `postcompose` -// // event, which is always triggered at every frame, and reconstructs -// // the stops layer’s vector context from internal variables -// /* eslint-disable no-underscore-dangle */ -// if (stopsLayer.renderer_) { -// ev.context = stopsLayer.renderer_.context; -// ev.inversePixelTransform = -// stopsLayer.renderer_.inversePixelTransform; -// /* eslint-enable no-underscore-dangle */ - -// const ctx = getVectorContext(ev); -// const bbox = fromExtent(map.getView().calculateExtent()); -// bbox.scale(1.05); - -// for (const course of Object.values(coursesSimulation.courses)) { -// if (!bbox.intersectsCoordinate(course.position)) { -// continue; -// } - -// const point = new Point(course.position); -// const color = network.lines[course.line].color; -// const style = courseStyle(color); - -// style.getImage().setRotation( -// view.getRotation() + -// course.angle -// ); - -// ctx.setStyle(style); -// ctx.drawGeometry(point); -// } -// } - -// map.render(); -// }); - -// map.on("singleclick", ev => { -// const mousePixel = map.getPixelFromCoordinate(ev.coordinate); -// const maxDistance = sizes.courseSize + sizes.courseInnerBorder; -// const clicked = []; - -// for (const course of Object.values(coursesSimulation.courses)) { -// const coursePixel = map.getPixelFromCoordinate(course.position); -// const dx = mousePixel[0] - coursePixel[0]; -// const dy = mousePixel[1] - coursePixel[1]; -// const distance = dx * dx + dy * dy; - -// if (distance <= maxDistance * maxDistance) { -// clicked.push(course.id); -// } -// } - -// // Sort selected courses in increasing departing/arrival time -// clicked.sort((id1, id2) => { -// const course1 = coursesSimulation.courses[id1]; -// const course2 = coursesSimulation.courses[id2]; - -// const time1 = ( -// course1.state === "moving" -// ? course1.arrivalTime -// : course1.departureTime -// ); - -// const time2 = ( -// course2.state === "moving" -// ? course2.arrivalTime -// : course2.departureTime -// ); - -// return time1 - time2; -// }); - -// onClick(clicked); -// }); -// };