Migrate to Maplibre GL with WebGL-based rendering
This commit is contained in:
parent
34f242b474
commit
7fb373633a
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
* vehicle’s 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) {
|
||||||
// We’re late, record the actual departure time
|
// We’re 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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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} à l’arrê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
|
@ -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,
|
||||||
|
|
|
@ -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 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);
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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];
|
|
||||||
};
|
|
||||||
|
|
|
@ -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
|
|
||||||
})];
|
|
||||||
};
|
|
|
@ -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 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);
|
||||||
|
// });
|
||||||
|
// };
|
Loading…
Reference in New Issue