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": {
|
||||
"@turf/along": "^6.3.0",
|
||||
"@turf/bearing": "^6.5.0",
|
||||
"@turf/clone": "^6.5.0",
|
||||
"@turf/helpers": "^6.3.0",
|
||||
"@turf/length": "^6.3.0",
|
||||
"@turf/projection": "^6.3.0",
|
||||
|
@ -19,7 +21,7 @@
|
|||
"csv-parse": "^4.15.4",
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"express": "^4.17.1",
|
||||
"ol": "^6.5.0",
|
||||
"maplibre-gl": "^2.1.9",
|
||||
"unzip-stream": "^0.3.1",
|
||||
"vue": "^3.0.5"
|
||||
},
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import axios from "axios";
|
||||
import turfAlong from "@turf/along";
|
||||
import turfBearing from "@turf/bearing";
|
||||
import * as turfProjection from "@turf/projection";
|
||||
import * as routing from "./routing.js";
|
||||
import network from "./network.json";
|
||||
|
@ -21,188 +22,208 @@ const minSpeed = 10 / 3600;
|
|||
// Normal speed of a vehicle
|
||||
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 {
|
||||
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
|
||||
this.id = id;
|
||||
this.properties.id = id;
|
||||
|
||||
// Line on which this vehicle operates
|
||||
this.line = null;
|
||||
this.properties.line = null;
|
||||
|
||||
// Line direction of this course
|
||||
this.direction = null;
|
||||
this.properties.direction = null;
|
||||
|
||||
// Stop to which this course is headed
|
||||
this.finalStop = null;
|
||||
this.properties.finalStop = null;
|
||||
|
||||
// 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)
|
||||
this.nextPassings = [];
|
||||
this.properties.nextPassings = [];
|
||||
|
||||
// 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)
|
||||
this.departureTime = 0;
|
||||
this.properties.departureTime = 0;
|
||||
|
||||
// Next stop that this course will reach
|
||||
this.arrivalStop = null;
|
||||
this.properties.arrivalStop = null;
|
||||
|
||||
// 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
|
||||
this.segment = null;
|
||||
this.properties.segment = null;
|
||||
|
||||
// Distance already travelled between the two stops (meters)
|
||||
this.traveledDistance = 0;
|
||||
this.properties.traveledDistance = 0;
|
||||
|
||||
// Current vehicle speed (meters per millisecond)
|
||||
this.speed = 0;
|
||||
|
||||
// Current vehicle latitude and longitude
|
||||
this.position = [0, 0];
|
||||
this.properties.speed = 0;
|
||||
|
||||
// Current vehicle bearing (clockwise degrees from north)
|
||||
this.angle = 0;
|
||||
this.properties.bearing = 0;
|
||||
}
|
||||
|
||||
/** Find a route between the current departure and arrival stops. */
|
||||
updateSegment() {
|
||||
if (this.departureStop === null || this.arrivalStop === null) {
|
||||
this.segment = null;
|
||||
const props = this.properties;
|
||||
|
||||
if (props.departureStop === null || props.arrivalStop === null) {
|
||||
props.segment = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const name = `${this.departureStop}-${this.arrivalStop}`;
|
||||
const name = `${props.departureStop}-${props.arrivalStop}`;
|
||||
|
||||
if (!(this.departureStop in network.stops)) {
|
||||
console.warn(`Unknown stop: ${this.departureStop}`);
|
||||
this.segment = null;
|
||||
if (!(props.departureStop in network.stops)) {
|
||||
console.warn(`Unknown stop: ${props.departureStop}`);
|
||||
props.segment = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(this.arrivalStop in network.stops)) {
|
||||
console.warn(`Unknown stop: ${this.arrivalStop}`);
|
||||
this.segment = null;
|
||||
if (!(props.arrivalStop in network.stops)) {
|
||||
console.warn(`Unknown stop: ${props.arrivalStop}`);
|
||||
props.segment = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.warn(`No route from ${this.departureStop} \
|
||||
to ${this.arrivalStop}`);
|
||||
if (props.segment === null) {
|
||||
console.warn(`No route from ${props.departureStop} \
|
||||
to ${props.arrivalStop}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge passings data received from the server. */
|
||||
receiveData(data) {
|
||||
this.line = data.line;
|
||||
this.direction = data.direction;
|
||||
this.finalStop = data.finalStopId;
|
||||
const props = this.properties;
|
||||
|
||||
props.line = data.line;
|
||||
props.direction = data.direction;
|
||||
props.finalStop = data.finalStopId;
|
||||
|
||||
const passings = Object.assign(
|
||||
Object.fromEntries(this.nextPassings),
|
||||
Object.fromEntries(props.nextPassings),
|
||||
Object.fromEntries(data.passings),
|
||||
);
|
||||
|
||||
// Remove older passings from next passings
|
||||
for (let [stop, _] of this.prevPassings) {
|
||||
for (let [stop, _] of props.prevPassings) {
|
||||
delete passings[stop];
|
||||
}
|
||||
|
||||
// Update departure time if still announced
|
||||
if (this.departureStop !== null) {
|
||||
if (this.departureStop in passings) {
|
||||
this.departureTime = passings[this.departureStop];
|
||||
delete passings[this.departureStop];
|
||||
if (props.departureStop !== null) {
|
||||
if (props.departureStop in passings) {
|
||||
props.departureTime = passings[props.departureStop];
|
||||
delete passings[props.departureStop];
|
||||
}
|
||||
}
|
||||
|
||||
// Update arrival time
|
||||
if (this.arrivalStop !== null) {
|
||||
if (this.arrivalStop in passings) {
|
||||
if (props.arrivalStop !== null) {
|
||||
if (props.arrivalStop in passings) {
|
||||
// Use announced time if available
|
||||
this.arrivalTime = passings[this.arrivalStop];
|
||||
delete passings[this.arrivalStop];
|
||||
props.arrivalTime = passings[props.arrivalStop];
|
||||
delete passings[props.arrivalStop];
|
||||
} else {
|
||||
// Otherwise, arrive using a normal speed from current position
|
||||
const segment = this.segment;
|
||||
const distance = segment.properties.length - this.traveledDistance;
|
||||
const segment = props.segment;
|
||||
const distance = segment.properties.length - props.traveledDistance;
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the vehicle state. */
|
||||
update() {
|
||||
const props = this.properties;
|
||||
const now = Date.now();
|
||||
|
||||
// When initializing, use the first available passing as start
|
||||
if (this.departureStop === null) {
|
||||
if (this.nextPassings.length > 0) {
|
||||
const [stopId, time] = this.nextPassings.shift();
|
||||
this.departureStop = stopId;
|
||||
this.departureTime = time;
|
||||
if (props.departureStop === null) {
|
||||
if (props.nextPassings.length > 0) {
|
||||
const [stopId, time] = props.nextPassings.shift();
|
||||
props.departureStop = stopId;
|
||||
props.departureTime = time;
|
||||
this.updateSegment();
|
||||
}
|
||||
}
|
||||
|
||||
// …and the second one as the arrival
|
||||
if (this.arrivalStop === null) {
|
||||
if (this.nextPassings.length > 0) {
|
||||
const [stopId, time] = this.nextPassings.shift();
|
||||
this.arrivalStop = stopId;
|
||||
this.arrivalTime = time;
|
||||
if (props.arrivalStop === null) {
|
||||
if (props.nextPassings.length > 0) {
|
||||
const [stopId, time] = props.nextPassings.shift();
|
||||
props.arrivalStop = stopId;
|
||||
props.arrivalTime = time;
|
||||
this.updateSegment();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.segment !== null) {
|
||||
const segment = this.segment;
|
||||
const distance = segment.properties.length - this.traveledDistance;
|
||||
const duration = this.arrivalTime - stopTime - now;
|
||||
if (props.segment !== null) {
|
||||
const segment = props.segment;
|
||||
const distance = segment.properties.length - props.traveledDistance;
|
||||
const duration = props.arrivalTime - stopTime - now;
|
||||
|
||||
// Arrive to the next stop
|
||||
if (distance === 0) {
|
||||
this.prevPassings.push([this.departureStop, this.departureTime]);
|
||||
this.departureStop = this.arrivalStop;
|
||||
this.departureTime = this.arrivalTime;
|
||||
props.prevPassings.push([
|
||||
props.departureStop,
|
||||
props.departureTime
|
||||
]);
|
||||
props.departureStop = props.arrivalStop;
|
||||
props.departureTime = props.arrivalTime;
|
||||
|
||||
if (this.nextPassings.length > 0) {
|
||||
const [stopId, time] = this.nextPassings.shift();
|
||||
this.arrivalStop = stopId;
|
||||
this.arrivalTime = time;
|
||||
if (props.nextPassings.length > 0) {
|
||||
const [stopId, time] = props.nextPassings.shift();
|
||||
props.arrivalStop = stopId;
|
||||
props.arrivalTime = time;
|
||||
} else {
|
||||
this.arrivalStop = null;
|
||||
this.arrivalTime = 0;
|
||||
props.arrivalStop = null;
|
||||
props.arrivalTime = 0;
|
||||
}
|
||||
|
||||
this.traveledDistance = 0;
|
||||
props.traveledDistance = 0;
|
||||
this.updateSegment();
|
||||
}
|
||||
|
||||
if (this.departureTime > now) {
|
||||
if (props.departureTime > now) {
|
||||
// Wait for departure
|
||||
this.speed = 0;
|
||||
props.speed = 0;
|
||||
} else {
|
||||
if (this.traveledDistance === 0 && this.speed === 0) {
|
||||
if (props.traveledDistance === 0 && props.speed === 0) {
|
||||
// We’re late, record the actual departure time
|
||||
this.departureTime = now;
|
||||
props.departureTime = now;
|
||||
}
|
||||
|
||||
// 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. */
|
||||
move(time) {
|
||||
if (this.segment === null) {
|
||||
const props = this.properties;
|
||||
const segment = props.segment;
|
||||
|
||||
if (props.segment === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.speed > 0) {
|
||||
this.traveledDistance = Math.min(
|
||||
this.traveledDistance + this.speed * time,
|
||||
this.segment.properties.length,
|
||||
if (props.speed > 0) {
|
||||
props.traveledDistance = Math.min(
|
||||
props.traveledDistance + props.speed * time,
|
||||
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 positionInFront;
|
||||
let positionAhead;
|
||||
|
||||
if (this.traveledDistance < angleStep / 2) {
|
||||
positionBehind = this.traveledDistance;
|
||||
positionInFront = angleStep;
|
||||
if (props.traveledDistance < angleStep / 2) {
|
||||
positionBehind = props.traveledDistance;
|
||||
positionAhead = angleStep;
|
||||
} else {
|
||||
positionBehind = this.traveledDistance - angleStep / 2;
|
||||
positionInFront = this.traveledDistance + angleStep / 2;
|
||||
positionBehind = props.traveledDistance - angleStep / 2;
|
||||
positionAhead = props.traveledDistance + angleStep / 2;
|
||||
}
|
||||
|
||||
const positions = [
|
||||
positionBehind,
|
||||
this.traveledDistance,
|
||||
positionInFront,
|
||||
].map(distance => turfProjection.toMercator(turfAlong(
|
||||
this.segment,
|
||||
props.traveledDistance,
|
||||
positionAhead,
|
||||
].map(distance => turfAlong(
|
||||
props.segment,
|
||||
distance / 1000
|
||||
)).geometry.coordinates);
|
||||
));
|
||||
|
||||
this.angle = Math.atan2(
|
||||
positions[0][1] - positions[2][1],
|
||||
positions[2][0] - positions[0][0]
|
||||
);
|
||||
this.geometry.coordinates = positions[1].geometry.coordinates;
|
||||
props.bearing = turfBearing(positions[0], positions[2]);
|
||||
}
|
||||
|
||||
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
|
||||
for (const id of Object.keys(courses)) {
|
||||
if (courses[id].departureStop === courses[id].finalStop) {
|
||||
if (courses[id].isFinished()) {
|
||||
delete courses[id];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,11 +12,10 @@ window.__network = network;
|
|||
// Create display panel
|
||||
const panel = document.querySelector("#panel");
|
||||
|
||||
const displayTime = date => [
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getSeconds()
|
||||
].map(number => number.toString().padStart(2, "0")).join(":");
|
||||
const timeFormat = new Intl.DateTimeFormat("fr-FR", {
|
||||
timeZone: "Europe/Paris",
|
||||
timeStyle: "medium",
|
||||
});
|
||||
|
||||
const timeToHTML = time => {
|
||||
const delta = Math.ceil((time - Date.now()) / 1000);
|
||||
|
@ -31,10 +30,22 @@ const timeToHTML = time => {
|
|||
};
|
||||
|
||||
setInterval(() => {
|
||||
const vehicleCount = Object.values(coursesSimulation.courses).length;
|
||||
const movingVehicles = Object.values(coursesSimulation.courses).filter(
|
||||
course => course.properties.speed > 0
|
||||
).length;
|
||||
|
||||
let html = `
|
||||
<dl>
|
||||
<dt>Heure actuelle</dt>
|
||||
<dd>${displayTime(new Date())}</dd>
|
||||
<dt>Heure locale</dt>
|
||||
<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>
|
||||
`;
|
||||
|
||||
|
@ -48,7 +59,7 @@ setInterval(() => {
|
|||
const passingsToHTML = passings => passings.map(([stopId, time]) => `
|
||||
<tr>
|
||||
<td>${stopToHTML(stopId)}</td>
|
||||
<td>${displayTime(new Date(time))}</td>
|
||||
<td>${timeFormat.format(time)}</td>
|
||||
</tr>
|
||||
`).join("\n");
|
||||
|
||||
|
@ -107,7 +118,7 @@ setInterval(() => {
|
|||
}, 1000);
|
||||
|
||||
// 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);
|
||||
|
||||
if (courses.length === 0) {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,5 +1,14 @@
|
|||
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.
|
||||
* @param {string} mainColor Original color.
|
||||
|
@ -40,9 +49,9 @@ export const cacheStyle = (createStyle, cacheKey = x => x) => {
|
|||
|
||||
export const sizes = {
|
||||
segmentOuter: 8,
|
||||
segmentInner: 6,
|
||||
stopRadius: 6,
|
||||
stopBorder: 1.5,
|
||||
segmentInner: 12,
|
||||
stopOuter: 8,
|
||||
stopInner: 6,
|
||||
courseSize: 15,
|
||||
courseOuterBorder: 13,
|
||||
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 proj from "ol/proj";
|
||||
import { getLayers as getTilesLayers } from "./tiles";
|
||||
import { getLayers as getNetworkLayers } from "./network";
|
||||
import { setupCoursesAnimation } from "./courses";
|
||||
import * as network from "./network";
|
||||
import * as vehicles from "./vehicles";
|
||||
import style from "./assets/style.json";
|
||||
|
||||
export const create = (target, coursesSimulation, onClick) => {
|
||||
const view = new View({
|
||||
center: proj.fromLonLat([3.88, 43.605]),
|
||||
zoom: 14,
|
||||
maxZoom: 22,
|
||||
constrainResolution: true
|
||||
});
|
||||
const mapId = "c1309cde-1b6e-4c9b-8b65-48512757d354";
|
||||
const mapKey = "6T8Cb9oYBFeDjDhpmNmd";
|
||||
|
||||
const tilesLayers = getTilesLayers();
|
||||
const networkLayers = getNetworkLayers();
|
||||
const stopsLayer = networkLayers[2];
|
||||
style.sources.openmaptiles.url = style.sources.openmaptiles.url.replace("{key}", mapKey);
|
||||
style.glyphs = style.glyphs.replace("{key}", mapKey);
|
||||
|
||||
const bounds = [
|
||||
"3.660507202148437", "43.49552248630757",
|
||||
"4.092750549316406", "43.73736766145917",
|
||||
];
|
||||
|
||||
export const create = (target, simulation, onClick) => {
|
||||
const map = new Map({
|
||||
target,
|
||||
layers: [...tilesLayers, ...networkLayers],
|
||||
view
|
||||
container: target,
|
||||
style,
|
||||
bounds,
|
||||
|
||||
maplibreLogo: true,
|
||||
maxPitch: 70,
|
||||
});
|
||||
|
||||
setupCoursesAnimation(map, coursesSimulation, stopsLayer, onClick);
|
||||
map.render();
|
||||
map.addControl(new NavigationControl());
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -1,146 +1,170 @@
|
|||
import * as turfHelpers from "@turf/helpers";
|
||||
import turfClone from "@turf/clone";
|
||||
|
||||
import network from "../../data/network.json";
|
||||
import * as routing from "../../data/routing.js";
|
||||
import { cacheStyle, makeBorderColor, sizes } from "./common";
|
||||
import { makeBorderColor, scaleZoom } from "./common";
|
||||
|
||||
import GeoJSON from "ol/format/GeoJSON";
|
||||
import VectorImageLayer from "ol/layer/VectorImage";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
const MIN_ZOOM_SEGMENTS = 9;
|
||||
const MIN_ZOOM_STOPS = 11;
|
||||
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" });
|
||||
|
||||
// Style used for the border of line segments
|
||||
const segmentBorderStyle = cacheStyle(
|
||||
color => new Style({
|
||||
stroke: new Stroke({
|
||||
color: makeBorderColor(color),
|
||||
width: sizes.segmentOuter
|
||||
})
|
||||
}),
|
||||
feature => feature.get("colors")[0],
|
||||
/** Compute a line offset to share a line span with other adjacent lines. */
|
||||
const getOffset = (size, index, count) => (
|
||||
-size / 2
|
||||
+ size / count / 2
|
||||
+ index * size / count
|
||||
);
|
||||
|
||||
// Style used for the inner part of line segments
|
||||
const segmentInnerStyle = cacheStyle(
|
||||
color => new Style({
|
||||
stroke: new Stroke({
|
||||
color,
|
||||
width: sizes.segmentInner
|
||||
})
|
||||
}),
|
||||
feature => feature.get("colors")[0],
|
||||
);
|
||||
/** Get the collection of line segments. */
|
||||
const getSegments = () => {
|
||||
// Aggregate segments spanning multiple routes
|
||||
const groupedSegments = {};
|
||||
|
||||
// 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 route of line.routes) {
|
||||
for (let i = 0; i + 1 < route.stops.length; ++i) {
|
||||
const stop1 = network.stops[route.stops[i]].id;
|
||||
const stop2 = network.stops[route.stops[i + 1]].id;
|
||||
|
||||
const segment = routing.findSegment(stop1, stop2);
|
||||
segment.properties.lines = [lineRef];
|
||||
segment.properties.colors = [line.color];
|
||||
const waypoints = routing.findPath(stop1, stop2);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
segmentsSource.addFeatures([...makeSegments(network.lines)]);
|
||||
const segments = [];
|
||||
|
||||
// Background layer on which the darker borders of line segments are drawn
|
||||
const segmentsBorderLayer = new VectorImageLayer({
|
||||
source: segmentsSource,
|
||||
renderOrder: lineFeaturesOrder,
|
||||
style: segmentBorderStyle,
|
||||
imageRatio: 2,
|
||||
});
|
||||
for (const segment of Object.values(groupedSegments)) {
|
||||
const lines = [...segment.properties.lines];
|
||||
lines.sort();
|
||||
const count = lines.length;
|
||||
|
||||
// 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,
|
||||
});
|
||||
// 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;
|
||||
|
||||
const stopsLayer = new VectorImageLayer({
|
||||
source: stopsSource,
|
||||
renderOrder: lineFeaturesOrder,
|
||||
style: stopStyle,
|
||||
imageRatio: 2,
|
||||
minZoom: 13,
|
||||
});
|
||||
props.innerSize = SEGMENT_SIZE / count;
|
||||
props.innerColor = network.lines[line].color;
|
||||
props.innerOffset = getOffset(SEGMENT_SIZE, index, count);
|
||||
|
||||
return [segmentsBorderLayer, segmentsInnerLayer, stopsLayer];
|
||||
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`]),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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