Migrate to Maplibre GL with WebGL-based rendering

This commit is contained in:
Mattéo Delabre 2022-07-09 15:50:53 -04:00
parent 34f242b474
commit 7fb373633a
Signed by: matteo
GPG Key ID: AE3FBD02DC583ABB
11 changed files with 6334 additions and 541 deletions

4758
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@turf/along": "^6.3.0", "@turf/along": "^6.3.0",
"@turf/bearing": "^6.5.0",
"@turf/clone": "^6.5.0",
"@turf/helpers": "^6.3.0", "@turf/helpers": "^6.3.0",
"@turf/length": "^6.3.0", "@turf/length": "^6.3.0",
"@turf/projection": "^6.3.0", "@turf/projection": "^6.3.0",
@ -19,7 +21,7 @@
"csv-parse": "^4.15.4", "csv-parse": "^4.15.4",
"dijkstrajs": "^1.0.1", "dijkstrajs": "^1.0.1",
"express": "^4.17.1", "express": "^4.17.1",
"ol": "^6.5.0", "maplibre-gl": "^2.1.9",
"unzip-stream": "^0.3.1", "unzip-stream": "^0.3.1",
"vue": "^3.0.5" "vue": "^3.0.5"
}, },

View File

@ -1,5 +1,6 @@
import axios from "axios"; import axios from "axios";
import turfAlong from "@turf/along"; import turfAlong from "@turf/along";
import turfBearing from "@turf/bearing";
import * as turfProjection from "@turf/projection"; import * as turfProjection from "@turf/projection";
import * as routing from "./routing.js"; import * as routing from "./routing.js";
import network from "./network.json"; import network from "./network.json";
@ -21,188 +22,208 @@ const minSpeed = 10 / 3600;
// Normal speed of a vehicle // Normal speed of a vehicle
const normSpeed = (2 * maxSpeed + minSpeed) / 3; const normSpeed = (2 * maxSpeed + minSpeed) / 3;
/** Simulate the evolution of a vehicle course in the network. */ /**
* GeoJSON feature representing a vehicle course with simulation of the
* vehicles movement along the course.
*/
class Course { class Course {
constructor(id) { constructor(id) {
this.type = "Feature";
// Current vehicle position (latitude and longitude)
this.geometry = {};
this.geometry.type = "Point";
this.geometry.coordinates = [0, 0];
this.properties = {};
// Unique identifier of this course // Unique identifier of this course
this.id = id; this.properties.id = id;
// Line on which this vehicle operates // Line on which this vehicle operates
this.line = null; this.properties.line = null;
// Line direction of this course // Line direction of this course
this.direction = null; this.properties.direction = null;
// Stop to which this course is headed // Stop to which this course is headed
this.finalStop = null; this.properties.finalStop = null;
// Previous stops that this course left (stop id/timestamp pairs) // Previous stops that this course left (stop id/timestamp pairs)
this.prevPassings = []; this.properties.prevPassings = [];
// Next stops that this course will leave (stop id/timestamp pairs) // Next stops that this course will leave (stop id/timestamp pairs)
this.nextPassings = []; this.properties.nextPassings = [];
// Stop that this course just left or will leave // Stop that this course just left or will leave
this.departureStop = null; this.properties.departureStop = null;
// Time at which the last stop was left or will be left (timestamp) // Time at which the last stop was left or will be left (timestamp)
this.departureTime = 0; this.properties.departureTime = 0;
// Next stop that this course will reach // Next stop that this course will reach
this.arrivalStop = null; this.properties.arrivalStop = null;
// Time at which the next stop will be left (timestamp) // Time at which the next stop will be left (timestamp)
this.arrivalTime = 0; this.properties.arrivalTime = 0;
// Route between the current departure and arrival stops // Route between the current departure and arrival stops
this.segment = null; this.properties.segment = null;
// Distance already travelled between the two stops (meters) // Distance already travelled between the two stops (meters)
this.traveledDistance = 0; this.properties.traveledDistance = 0;
// Current vehicle speed (meters per millisecond) // Current vehicle speed (meters per millisecond)
this.speed = 0; this.properties.speed = 0;
// Current vehicle latitude and longitude
this.position = [0, 0];
// Current vehicle bearing (clockwise degrees from north) // Current vehicle bearing (clockwise degrees from north)
this.angle = 0; this.properties.bearing = 0;
} }
/** Find a route between the current departure and arrival stops. */ /** Find a route between the current departure and arrival stops. */
updateSegment() { updateSegment() {
if (this.departureStop === null || this.arrivalStop === null) { const props = this.properties;
this.segment = null;
if (props.departureStop === null || props.arrivalStop === null) {
props.segment = null;
return; return;
} }
const name = `${this.departureStop}-${this.arrivalStop}`; const name = `${props.departureStop}-${props.arrivalStop}`;
if (!(this.departureStop in network.stops)) { if (!(props.departureStop in network.stops)) {
console.warn(`Unknown stop: ${this.departureStop}`); console.warn(`Unknown stop: ${props.departureStop}`);
this.segment = null; props.segment = null;
return; return;
} }
if (!(this.arrivalStop in network.stops)) { if (!(props.arrivalStop in network.stops)) {
console.warn(`Unknown stop: ${this.arrivalStop}`); console.warn(`Unknown stop: ${props.arrivalStop}`);
this.segment = null; props.segment = null;
return; return;
} }
// Compute a custom route between two stops // Compute a custom route between two stops
this.segment = routing.findSegment(this.departureStop, this.arrivalStop); props.segment = routing.findSegment(
props.departureStop,
props.arrivalStop,
);
if (this.segment === null) { if (props.segment === null) {
console.warn(`No route from ${this.departureStop} \ console.warn(`No route from ${props.departureStop} \
to ${this.arrivalStop}`); to ${props.arrivalStop}`);
} }
} }
/** Merge passings data received from the server. */ /** Merge passings data received from the server. */
receiveData(data) { receiveData(data) {
this.line = data.line; const props = this.properties;
this.direction = data.direction;
this.finalStop = data.finalStopId; props.line = data.line;
props.direction = data.direction;
props.finalStop = data.finalStopId;
const passings = Object.assign( const passings = Object.assign(
Object.fromEntries(this.nextPassings), Object.fromEntries(props.nextPassings),
Object.fromEntries(data.passings), Object.fromEntries(data.passings),
); );
// Remove older passings from next passings // Remove older passings from next passings
for (let [stop, _] of this.prevPassings) { for (let [stop, _] of props.prevPassings) {
delete passings[stop]; delete passings[stop];
} }
// Update departure time if still announced // Update departure time if still announced
if (this.departureStop !== null) { if (props.departureStop !== null) {
if (this.departureStop in passings) { if (props.departureStop in passings) {
this.departureTime = passings[this.departureStop]; props.departureTime = passings[props.departureStop];
delete passings[this.departureStop]; delete passings[props.departureStop];
} }
} }
// Update arrival time // Update arrival time
if (this.arrivalStop !== null) { if (props.arrivalStop !== null) {
if (this.arrivalStop in passings) { if (props.arrivalStop in passings) {
// Use announced time if available // Use announced time if available
this.arrivalTime = passings[this.arrivalStop]; props.arrivalTime = passings[props.arrivalStop];
delete passings[this.arrivalStop]; delete passings[props.arrivalStop];
} else { } else {
// Otherwise, arrive using a normal speed from current position // Otherwise, arrive using a normal speed from current position
const segment = this.segment; const segment = props.segment;
const distance = segment.properties.length - this.traveledDistance; const distance = segment.properties.length - props.traveledDistance;
const time = Math.floor(distance / normSpeed); const time = Math.floor(distance / normSpeed);
this.arrivalTime = Date.now() + time; props.arrivalTime = Date.now() + time;
} }
} }
this.nextPassings = Object.entries(passings).sort( props.nextPassings = Object.entries(passings).sort(
([, time1], [, time2]) => time1 - time2 ([, time1], [, time2]) => time1 - time2
); );
} }
/** Update the vehicle state. */ /** Update the vehicle state. */
update() { update() {
const props = this.properties;
const now = Date.now(); const now = Date.now();
// When initializing, use the first available passing as start // When initializing, use the first available passing as start
if (this.departureStop === null) { if (props.departureStop === null) {
if (this.nextPassings.length > 0) { if (props.nextPassings.length > 0) {
const [stopId, time] = this.nextPassings.shift(); const [stopId, time] = props.nextPassings.shift();
this.departureStop = stopId; props.departureStop = stopId;
this.departureTime = time; props.departureTime = time;
this.updateSegment(); this.updateSegment();
} }
} }
// …and the second one as the arrival // …and the second one as the arrival
if (this.arrivalStop === null) { if (props.arrivalStop === null) {
if (this.nextPassings.length > 0) { if (props.nextPassings.length > 0) {
const [stopId, time] = this.nextPassings.shift(); const [stopId, time] = props.nextPassings.shift();
this.arrivalStop = stopId; props.arrivalStop = stopId;
this.arrivalTime = time; props.arrivalTime = time;
this.updateSegment(); this.updateSegment();
} }
} }
if (this.segment !== null) { if (props.segment !== null) {
const segment = this.segment; const segment = props.segment;
const distance = segment.properties.length - this.traveledDistance; const distance = segment.properties.length - props.traveledDistance;
const duration = this.arrivalTime - stopTime - now; const duration = props.arrivalTime - stopTime - now;
// Arrive to the next stop // Arrive to the next stop
if (distance === 0) { if (distance === 0) {
this.prevPassings.push([this.departureStop, this.departureTime]); props.prevPassings.push([
this.departureStop = this.arrivalStop; props.departureStop,
this.departureTime = this.arrivalTime; props.departureTime
]);
props.departureStop = props.arrivalStop;
props.departureTime = props.arrivalTime;
if (this.nextPassings.length > 0) { if (props.nextPassings.length > 0) {
const [stopId, time] = this.nextPassings.shift(); const [stopId, time] = props.nextPassings.shift();
this.arrivalStop = stopId; props.arrivalStop = stopId;
this.arrivalTime = time; props.arrivalTime = time;
} else { } else {
this.arrivalStop = null; props.arrivalStop = null;
this.arrivalTime = 0; props.arrivalTime = 0;
} }
this.traveledDistance = 0; props.traveledDistance = 0;
this.updateSegment(); this.updateSegment();
} }
if (this.departureTime > now) { if (props.departureTime > now) {
// Wait for departure // Wait for departure
this.speed = 0; props.speed = 0;
} else { } else {
if (this.traveledDistance === 0 && this.speed === 0) { if (props.traveledDistance === 0 && props.speed === 0) {
// Were late, record the actual departure time // Were late, record the actual departure time
this.departureTime = now; props.departureTime = now;
} }
// Update current speed to arrive on time if possible // Update current speed to arrive on time if possible
this.speed = Course.computeSpeed(distance, duration); props.speed = Course.computeSpeed(distance, duration);
} }
} }
@ -211,44 +232,49 @@ to ${this.arrivalStop}`);
/** Integrate the current vehicle speed and update distance. */ /** Integrate the current vehicle speed and update distance. */
move(time) { move(time) {
if (this.segment === null) { const props = this.properties;
const segment = props.segment;
if (props.segment === null) {
return; return;
} }
if (this.speed > 0) { if (props.speed > 0) {
this.traveledDistance = Math.min( props.traveledDistance = Math.min(
this.traveledDistance + this.speed * time, props.traveledDistance + props.speed * time,
this.segment.properties.length, segment.properties.length,
); );
} }
// Compute updated position and angle based on a small step // Compute angle based on a small step along the segment
let positionBehind; let positionBehind;
let positionInFront; let positionAhead;
if (this.traveledDistance < angleStep / 2) { if (props.traveledDistance < angleStep / 2) {
positionBehind = this.traveledDistance; positionBehind = props.traveledDistance;
positionInFront = angleStep; positionAhead = angleStep;
} else { } else {
positionBehind = this.traveledDistance - angleStep / 2; positionBehind = props.traveledDistance - angleStep / 2;
positionInFront = this.traveledDistance + angleStep / 2; positionAhead = props.traveledDistance + angleStep / 2;
} }
const positions = [ const positions = [
positionBehind, positionBehind,
this.traveledDistance, props.traveledDistance,
positionInFront, positionAhead,
].map(distance => turfProjection.toMercator(turfAlong( ].map(distance => turfAlong(
this.segment, props.segment,
distance / 1000 distance / 1000
)).geometry.coordinates); ));
this.angle = Math.atan2( this.geometry.coordinates = positions[1].geometry.coordinates;
positions[0][1] - positions[2][1], props.bearing = turfBearing(positions[0], positions[2]);
positions[2][0] - positions[0][0] }
);
this.position = positions[1]; /** Check if a course is finished. */
isFinished() {
const props = this.properties;
return props.departureStop === props.finalStop;
} }
/** /**
@ -291,7 +317,7 @@ const updateData = async courses => {
// Remove stale courses // Remove stale courses
for (const id of Object.keys(courses)) { for (const id of Object.keys(courses)) {
if (courses[id].departureStop === courses[id].finalStop) { if (courses[id].isFinished()) {
delete courses[id]; delete courses[id];
} }
} }

View File

@ -12,11 +12,10 @@ window.__network = network;
// Create display panel // Create display panel
const panel = document.querySelector("#panel"); const panel = document.querySelector("#panel");
const displayTime = date => [ const timeFormat = new Intl.DateTimeFormat("fr-FR", {
date.getHours(), timeZone: "Europe/Paris",
date.getMinutes(), timeStyle: "medium",
date.getSeconds() });
].map(number => number.toString().padStart(2, "0")).join(":");
const timeToHTML = time => { const timeToHTML = time => {
const delta = Math.ceil((time - Date.now()) / 1000); const delta = Math.ceil((time - Date.now()) / 1000);
@ -31,10 +30,22 @@ const timeToHTML = time => {
}; };
setInterval(() => { setInterval(() => {
const vehicleCount = Object.values(coursesSimulation.courses).length;
const movingVehicles = Object.values(coursesSimulation.courses).filter(
course => course.properties.speed > 0
).length;
let html = ` let html = `
<dl> <dl>
<dt>Heure actuelle</dt> <dt>Heure locale</dt>
<dd>${displayTime(new Date())}</dd> <dd>${timeFormat.format(Date.now())}</dd>
</dl>
<dl>
<dt>Véhicules sur la carte</dt>
<dd>
${movingVehicles} en mouvement,
${vehicleCount - movingVehicles} à larrêt
</dd>
</dl> </dl>
`; `;
@ -48,7 +59,7 @@ setInterval(() => {
const passingsToHTML = passings => passings.map(([stopId, time]) => ` const passingsToHTML = passings => passings.map(([stopId, time]) => `
<tr> <tr>
<td>${stopToHTML(stopId)}</td> <td>${stopToHTML(stopId)}</td>
<td>${displayTime(new Date(time))}</td> <td>${timeFormat.format(time)}</td>
</tr> </tr>
`).join("\n"); `).join("\n");
@ -107,7 +118,7 @@ setInterval(() => {
}, 1000); }, 1000);
// Create the network and courses map // Create the network and courses map
map.create(/* map = */ "map", coursesSimulation, courses => { window.__map = map.create(/* map = */ "map", coursesSimulation, courses => {
const index = courses.indexOf(selectedCourse); const index = courses.indexOf(selectedCourse);
if (courses.length === 0) { if (courses.length === 0) {

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,14 @@
import color from "color"; import color from "color";
/** Make a value scale according to the zoom level. */
export const scaleZoom = value => [
"interpolate",
["exponential", 2],
["zoom"],
10, value,
24, ["*", value, ["^", 2, 6]]
];
/** /**
* Turn the main color of a line into a color suitable for using as a border. * Turn the main color of a line into a color suitable for using as a border.
* @param {string} mainColor Original color. * @param {string} mainColor Original color.
@ -40,9 +49,9 @@ export const cacheStyle = (createStyle, cacheKey = x => x) => {
export const sizes = { export const sizes = {
segmentOuter: 8, segmentOuter: 8,
segmentInner: 6, segmentInner: 12,
stopRadius: 6, stopOuter: 8,
stopBorder: 1.5, stopInner: 6,
courseSize: 15, courseSize: 15,
courseOuterBorder: 13, courseOuterBorder: 13,
courseBorder: 10, courseBorder: 10,

View File

@ -1,139 +0,0 @@
import { getVectorContext } from "ol/render";
import Point from "ol/geom/Point";
import { fromExtent } from 'ol/geom/Polygon';
import { Style, Icon } from "ol/style";
import { sizes, cacheStyle, makeBorderColor, makeCourseColor } from "./common";
import network from "../../data/network.json";
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 layers 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 layers 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);
});
};

View File

@ -1,31 +1,48 @@
import "ol/ol.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { Map, NavigationControl } from "maplibre-gl";
import { Map, View } from "ol"; import * as network from "./network";
import * as proj from "ol/proj"; import * as vehicles from "./vehicles";
import { getLayers as getTilesLayers } from "./tiles"; import style from "./assets/style.json";
import { getLayers as getNetworkLayers } from "./network";
import { setupCoursesAnimation } from "./courses";
export const create = (target, coursesSimulation, onClick) => { const mapId = "c1309cde-1b6e-4c9b-8b65-48512757d354";
const view = new View({ const mapKey = "6T8Cb9oYBFeDjDhpmNmd";
center: proj.fromLonLat([3.88, 43.605]),
zoom: 14,
maxZoom: 22,
constrainResolution: true
});
const tilesLayers = getTilesLayers(); style.sources.openmaptiles.url = style.sources.openmaptiles.url.replace("{key}", mapKey);
const networkLayers = getNetworkLayers(); style.glyphs = style.glyphs.replace("{key}", mapKey);
const stopsLayer = networkLayers[2];
const bounds = [
"3.660507202148437", "43.49552248630757",
"4.092750549316406", "43.73736766145917",
];
export const create = (target, simulation, onClick) => {
const map = new Map({ const map = new Map({
target, container: target,
layers: [...tilesLayers, ...networkLayers], style,
view bounds,
maplibreLogo: true,
maxPitch: 70,
}); });
setupCoursesAnimation(map, coursesSimulation, stopsLayer, onClick); map.addControl(new NavigationControl());
map.render();
const animate = () => {
simulation.update();
vehicles.update(map, simulation.courses);
requestAnimationFrame(animate);
};
map.on("load", () => {
network.addLayers(map);
vehicles.addLayers(map);
// Move 3D buildings to the front of custom layers
map.moveLayer("building-3d");
animate();
});
return map; return map;
}; };

View File

@ -1,146 +1,170 @@
import * as turfHelpers from "@turf/helpers";
import turfClone from "@turf/clone";
import network from "../../data/network.json"; import network from "../../data/network.json";
import * as routing from "../../data/routing.js"; import * as routing from "../../data/routing.js";
import { cacheStyle, makeBorderColor, sizes } from "./common"; import { makeBorderColor, scaleZoom } from "./common";
import GeoJSON from "ol/format/GeoJSON"; const MIN_ZOOM_SEGMENTS = 9;
import VectorImageLayer from "ol/layer/VectorImage"; const MIN_ZOOM_STOPS = 11;
import VectorSource from "ol/source/Vector"; const MAX_ZOOM = 22;
import { Style, Fill, Stroke, Circle } from "ol/style"; const BORDER_SIZE = 3;
const SEGMENT_SIZE = 6;
const STOP_SIZE = 12;
const geojsonReader = new GeoJSON({ featureProjection: "EPSG:3857" }); /** Compute a line offset to share a line span with other adjacent lines. */
const getOffset = (size, index, count) => (
// Style used for the border of line segments -size / 2
const segmentBorderStyle = cacheStyle( + size / count / 2
color => new Style({ + index * size / count
stroke: new Stroke({
color: makeBorderColor(color),
width: sizes.segmentOuter
})
}),
feature => feature.get("colors")[0],
); );
// Style used for the inner part of line segments /** Get the collection of line segments. */
const segmentInnerStyle = cacheStyle( const getSegments = () => {
color => new Style({ // Aggregate segments spanning multiple routes
stroke: new Stroke({ const groupedSegments = {};
color,
width: sizes.segmentInner
})
}),
feature => feature.get("colors")[0],
);
// Style used for line stops
const stopStyle = cacheStyle(
color => new Style({
image: new Circle({
fill: new Fill({ color }),
stroke: new Stroke({
color: makeBorderColor(color),
width: sizes.stopBorder
}),
radius: sizes.stopRadius
})
}),
feature => feature.get("colors")[0],
);
/**
* Order for features related to a network line inside the same layer.
* @param {Feature} feature1 First feature to order.
* @param {Feature} feature2 Second feature to order.
* @returns {number} -1 if `feature1` comes before `feature2`, 1 if
* `feature2` comes before `feature1`, 0 if the order is irrelevant.
*/
const lineFeaturesOrder = (feature1, feature2) => {
// Place features with no lines attributed on the background
const lines1 = feature1.get("lines");
if (lines1.length === 0) {
return -1;
}
const lines2 = feature2.get("lines");
if (lines2.length === 0) {
return 1;
}
// Draw lines with a lower numeric value first
return Math.max(...lines2) - Math.max(...lines1);
};
/**
* Create the list of layers for displaying the transit network.
* @returns {Array.<Layer>} List of map layers.
*/
export const getLayers = () => {
const segmentsSource = new VectorSource();
const stopsSource = new VectorSource();
// Turn GeoJSON stops list into a vector source
const readStops = hash => Object.values(hash).map(json => {
json.properties.lines = json.properties.routes.map(([lineRef]) => lineRef);
if (json.properties.lines.length >= 1) {
json.properties.colors = json.properties.lines.map(
lineRef => network.lines[lineRef].color
);
} else {
json.properties.colors = ["#FFFFFF"];
}
return geojsonReader.readFeature(json);
});
stopsSource.addFeatures(readStops(network.stops));
// Link stops with segments
const makeSegments = function* (lines) {
for (const [lineRef, line] of Object.entries(network.lines)) { for (const [lineRef, line] of Object.entries(network.lines)) {
for (const route of line.routes) { for (const route of line.routes) {
for (let i = 0; i + 1 < route.stops.length; ++i) { for (let i = 0; i + 1 < route.stops.length; ++i) {
const stop1 = network.stops[route.stops[i]].id; const stop1 = network.stops[route.stops[i]].id;
const stop2 = network.stops[route.stops[i + 1]].id; const stop2 = network.stops[route.stops[i + 1]].id;
const segment = routing.findSegment(stop1, stop2); const waypoints = routing.findPath(stop1, stop2);
segment.properties.lines = [lineRef];
segment.properties.colors = [line.color];
yield geojsonReader.readFeature(segment); for (let j = 0; j + 1 < waypoints.length; ++j) {
const point1 = waypoints[j];
const point2 = waypoints[j + 1];
const id = `${point1}-${point2}`;
if (id in groupedSegments) {
groupedSegments[id].properties.lines.add(lineRef);
} else {
groupedSegments[id] = (
turfClone(network.navigation[point1][point2])
);
groupedSegments[id].properties.lines = new Set();
groupedSegments[id].properties.lines.add(lineRef);
} }
} }
} }
}
}
const segments = [];
for (const segment of Object.values(groupedSegments)) {
const lines = [...segment.properties.lines];
lines.sort();
const count = lines.length;
// Duplicate and offset segments for each route
for (const [index, line] of lines.entries()) {
const feature = turfClone(segment);
const props = feature.properties;
delete props.lines;
props.line = line;
props.innerSize = SEGMENT_SIZE / count;
props.innerColor = network.lines[line].color;
props.innerOffset = getOffset(SEGMENT_SIZE, index, count);
props.outerSize = (SEGMENT_SIZE + BORDER_SIZE) / count;
props.outerColor = makeBorderColor(props.innerColor);
props.outerOffset = getOffset(SEGMENT_SIZE + BORDER_SIZE, index, count);
segments.push(feature);
}
}
return turfHelpers.featureCollection(segments);
};
/** Get the collection of stops. */
const getStops = () => {
const stops = [];
for (const stop of Object.values(network.stops)) {
const lines = [...new Set(
stop.properties.routes.map(([lineRef]) => lineRef)
)];
lines.sort();
const count = lines.length;
// Duplicate and offset stops for each route
for (const [index, line] of lines.entries()) {
const feature = turfClone(stop);
const props = feature.properties;
props.line = line;
props.innerSize = (count - index) * STOP_SIZE / 2 / count;
props.innerColor = network.lines[line].color;
if (index === 0) {
props.outerSize = (STOP_SIZE + BORDER_SIZE) / 2;
} else {
props.outerSize = 0;
}
props.outerColor = makeBorderColor(props.innerColor);
stops.push(feature);
}
}
return turfHelpers.featureCollection(stops);
};
/**
* Add layers that display the transit network on the map.
* @param {Map} map The map to add the layers to.
*/
export const addLayers = map => {
map.addSource("lines", {
type: "geojson",
data: getSegments(),
});
map.addSource("stops", {
type: "geojson",
data: getStops(),
});
for (const lineRef of Object.keys(network.lines)) {
for (const kind of ["outer", "inner"]) {
map.addLayer({
id: `line-${lineRef}-${kind}`,
type: "line",
source: "lines",
filter: ["==", "line", lineRef],
minzoom: MIN_ZOOM_SEGMENTS,
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": ["get", `${kind}Color`],
"line-width": scaleZoom(["get", `${kind}Size`]),
"line-offset": scaleZoom(["get", `${kind}Offset`]),
},
});
}
}
for (const lineRef of Object.keys(network.lines)) {
for (const kind of ["outer", "inner"]) {
map.addLayer({
id: `stops-${lineRef}-${kind}`,
type: "circle",
source: "stops",
filter: ["==", "line", lineRef],
minzoom: MIN_ZOOM_STOPS,
paint: {
"circle-pitch-alignment": "map",
"circle-color": ["get", `${kind}Color`],
"circle-radius": scaleZoom(["get", `${kind}Size`]),
},
});
}
}
}; };
segmentsSource.addFeatures([...makeSegments(network.lines)]);
// Background layer on which the darker borders of line segments are drawn
const segmentsBorderLayer = new VectorImageLayer({
source: segmentsSource,
renderOrder: lineFeaturesOrder,
style: segmentBorderStyle,
imageRatio: 2,
});
// Foreground layer on which the lighter inner part of line segments are
// drawn. The two layers are separated so that forks blend nicely together
const segmentsInnerLayer = new VectorImageLayer({
source: segmentsSource,
renderOrder: lineFeaturesOrder,
style: segmentInnerStyle,
imageRatio: 2,
});
const stopsLayer = new VectorImageLayer({
source: stopsSource,
renderOrder: lineFeaturesOrder,
style: stopStyle,
imageRatio: 2,
minZoom: 13,
});
return [segmentsBorderLayer, segmentsInnerLayer, stopsLayer];
};

View File

@ -1,23 +0,0 @@
import TileLayer from "ol/layer/Tile";
import XYZSource from "ol/source/XYZ";
const mapboxToken = "pk.eyJ1IjoibWF0dGVvZGVsYWJyZSIsImEiOiJja2NxaTUyMmUwcmFhMn\
h0NmFsdzQ3emxqIn0.cyxF0h36emIMTk3cc4VqUw";
/**
* Create the list of layers for displaying the background map.
* @returns {Array.<Layer>} List of map layers.
*/
export const getLayers = () => {
const backgroundSource = new XYZSource({
url: `https://api.mapbox.com/${[
"styles", "v1", "mapbox", "streets-v11",
"tiles", "512", "{z}", "{x}", "{y}"
].join("/")}?access_token=${mapboxToken}`,
tileSize: [512, 512]
});
return [new TileLayer({
source: backgroundSource
})];
};

233
src/front/map/vehicles.js Normal file
View File

@ -0,0 +1,233 @@
// 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");
canvas.width = size;
canvas.height = size;
const cx = size / 2;
const cy = size / 2;
const iconSize = 0.4 * size;
const borderSize = 0.2 * size;
context.fillStyle = "black";
context.strokeStyle = "black";
context.lineWidth = borderSize;
context.lineJoin = "round";
context.miterLimit = 200000;
context.beginPath();
context.moveTo(cx - 0.3 * iconSize, cy + 0.5 * iconSize);
context.lineTo(cx, cy - 0.5 * iconSize);
context.lineTo(cx + 0.3 * iconSize, cy + 0.5 * iconSize);
context.closePath();
context.stroke();
context.fill();
return {
width: size,
height: size,
data: context.getImageData(0, 0, size, size).data,
};
};
/**
* Add layers that display the live vehicle positions on the map.
* @param {Map} map The map to add the layers to.
*/
export const addLayers = map => {
map.addImage("vehicle", makeVehicleIcon(128), {sdf: true});
map.addSource("vehicles", {
type: "geojson",
data: turfHelpers.featureCollection([]),
});
for (let [kind, factor] of [
["outer", 0.3],
["border", 0.25],
["inner", 0.2],
]) {
map.addLayer({
id: `vehicles-${kind}`,
type: "symbol",
source: "vehicles",
layout: {
"icon-image": "vehicle",
"icon-size": scaleZoom(factor),
"icon-allow-overlap": true,
"icon-rotation-alignment": "map",
"icon-rotate": ["get", "bearing"],
},
paint: {
"icon-color": ["get", `${kind}Color`],
},
});
}
};
/**
* Update the vehicle positions on the map.
* @param {Map} map The map to update.
* @param {Array<Object>} courses The active courses.
*/
export const update = (map, courses) => {
const features = Object.values(courses);
for (let course of features) {
const props = course.properties;
props.borderColor = network.lines[props.line].color;
props.innerColor = makeCourseColor(props.borderColor);
props.outerColor = makeBorderColor(props.borderColor);
}
map.getSource("vehicles").setData(
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 layers 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 layers 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);
// });
// };