326 lines
9.1 KiB
JavaScript
326 lines
9.1 KiB
JavaScript
require("ol/ol.css");
|
||
|
||
const { Map, View } = require("ol");
|
||
|
||
const GeoJSON = require("ol/format/GeoJSON").default;
|
||
const reader = new GeoJSON({ featureProjection: "EPSG:3857" });
|
||
|
||
const TileLayer = require("ol/layer/Tile").default;
|
||
const XYZSource = require("ol/source/XYZ").default;
|
||
|
||
const VectorLayer = require("ol/layer/Vector").default;
|
||
const VectorSource = require("ol/source/Vector").default;
|
||
const { getVectorContext } = require("ol/render");
|
||
|
||
const Point = require("ol/geom/Point").default;
|
||
|
||
const proj = require("ol/proj");
|
||
|
||
const { Style, Fill, Stroke, Circle, Icon } = require("ol/style");
|
||
const colorModule = require("color");
|
||
|
||
const mapboxToken = "pk.eyJ1IjoibWF0dGVvZGVsYWJyZSIsImEiOiJja2NxaTUyMmUwcmFhMn\
|
||
h0NmFsdzQ3emxqIn0.cyxF0h36emIMTk3cc4VqUw";
|
||
|
||
const simulation = require("../tam/simulation");
|
||
const network = require("../tam/network.json");
|
||
|
||
const lineFeaturesOrder = (feature1, feature2) => {
|
||
const lines1 = feature1.get("lines");
|
||
|
||
if (lines1.length === 0) {
|
||
return -1;
|
||
}
|
||
|
||
const lines2 = feature2.get("lines");
|
||
|
||
if (lines2.length === 0) {
|
||
return 1;
|
||
}
|
||
|
||
return Math.min(...lines1) - Math.min(...lines2);
|
||
};
|
||
|
||
const makeDataSources = () => {
|
||
const segmentsSource = new VectorSource();
|
||
const stopsSource = new VectorSource();
|
||
|
||
const readFeatures = hash => Object.values(hash).map(json => {
|
||
json.properties.lines = json.properties.routes.filter(
|
||
|
||
// Only consider normal routes (excluding alternate routes)
|
||
([lineRef, routeRef]) =>
|
||
network.lines[lineRef].routes[routeRef].state === "normal"
|
||
).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 reader.readFeature(json);
|
||
});
|
||
|
||
segmentsSource.addFeatures(readFeatures(network.segments));
|
||
stopsSource.addFeatures(readFeatures(network.stops));
|
||
return { segmentsSource, stopsSource };
|
||
};
|
||
|
||
const makeBorderColor = mainColor => {
|
||
const hsl = colorModule(mainColor).hsl();
|
||
|
||
hsl.color = Math.max(0, hsl.color[2] -= 20);
|
||
return hsl.hex();
|
||
};
|
||
|
||
const makeCourseColor = mainColor => {
|
||
const hsl = colorModule(mainColor).hsl();
|
||
|
||
hsl.color = Math.max(0, hsl.color[2] += 10);
|
||
return hsl.hex();
|
||
};
|
||
|
||
const sizes = {
|
||
segmentOuter: 8,
|
||
segmentInner: 6,
|
||
stopRadius: 6,
|
||
stopBorder: 1.5,
|
||
courseSize: 15,
|
||
courseOuterBorder: 13,
|
||
courseBorder: 10,
|
||
courseInnerBorder: 7
|
||
};
|
||
|
||
const segmentBorderStyle = feature => new Style({
|
||
stroke: new Stroke({
|
||
color: makeBorderColor(feature.get("colors")[0]),
|
||
width: sizes.segmentOuter
|
||
})
|
||
});
|
||
|
||
const segmentInnerStyle = feature => new Style({
|
||
stroke: new Stroke({
|
||
color: feature.get("colors")[0],
|
||
width: sizes.segmentInner
|
||
})
|
||
});
|
||
|
||
const stopStyle = feature => new Style({
|
||
image: new Circle({
|
||
fill: new Fill({
|
||
color: feature.get("colors")[0]
|
||
}),
|
||
stroke: new Stroke({
|
||
color: makeBorderColor(feature.get("colors")[0]),
|
||
width: sizes.stopBorder
|
||
}),
|
||
radius: sizes.stopRadius
|
||
})
|
||
});
|
||
|
||
const courseStyles = {};
|
||
|
||
const getCourseStyle = lineColor => {
|
||
if (!(lineColor in courseStyles)) {
|
||
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();
|
||
}
|
||
|
||
courseStyles[lineColor] = new Style({
|
||
image: new Icon({
|
||
img: icon,
|
||
imgSize: [icon.width, icon.height]
|
||
})
|
||
});
|
||
}
|
||
|
||
return courseStyles[lineColor];
|
||
};
|
||
|
||
const createMap = target => {
|
||
|
||
// Map background
|
||
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]
|
||
});
|
||
|
||
const backgroundLayer = new TileLayer({
|
||
source: backgroundSource
|
||
});
|
||
|
||
// Static data overlay
|
||
const { segmentsSource, stopsSource } = makeDataSources();
|
||
|
||
const segmentsBorderLayer = new VectorLayer({
|
||
source: segmentsSource,
|
||
renderOrder: lineFeaturesOrder,
|
||
style: segmentBorderStyle,
|
||
|
||
updateWhileInteracting: true,
|
||
updateWhileAnimating: true
|
||
});
|
||
|
||
const segmentsInnerLayer = new VectorLayer({
|
||
source: segmentsSource,
|
||
renderOrder: lineFeaturesOrder,
|
||
style: segmentInnerStyle,
|
||
|
||
updateWhileInteracting: true,
|
||
updateWhileAnimating: true
|
||
});
|
||
|
||
const stopsLayer = new VectorLayer({
|
||
source: stopsSource,
|
||
renderOrder: lineFeaturesOrder,
|
||
style: stopStyle,
|
||
|
||
minZoom: 13,
|
||
updateWhileInteracting: true,
|
||
updateWhileAnimating: true
|
||
});
|
||
|
||
// Setup map
|
||
const view = new View({
|
||
center: proj.fromLonLat([3.88, 43.605]),
|
||
zoom: 14,
|
||
maxZoom: 22,
|
||
constrainResolution: true
|
||
});
|
||
|
||
const map = new Map({
|
||
target,
|
||
layers: [
|
||
backgroundLayer,
|
||
segmentsBorderLayer,
|
||
segmentsInnerLayer,
|
||
stopsLayer
|
||
],
|
||
view
|
||
});
|
||
|
||
// Run courses simulation
|
||
const simulInstance = simulation.start();
|
||
|
||
// Course on which the view is currently focused
|
||
let focusedCourse = null;
|
||
|
||
const startFocus = courseId => {
|
||
if (courseId in simulInstance.courses) {
|
||
const course = simulInstance.courses[courseId];
|
||
|
||
view.animate({
|
||
center: course.position,
|
||
duration: 500
|
||
}, () => {
|
||
focusedCourse = courseId;
|
||
});
|
||
}
|
||
};
|
||
|
||
const stopFocus = () => {
|
||
focusedCourse = null;
|
||
};
|
||
|
||
// Draw courses directly on the map
|
||
map.on("postcompose", ev => {
|
||
simulInstance.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);
|
||
|
||
for (const course of Object.values(simulInstance.courses)) {
|
||
const color = network.lines[course.line].color;
|
||
const style = getCourseStyle(color);
|
||
|
||
style.getImage().setRotation(course.angle);
|
||
ctx.setStyle(style);
|
||
|
||
const point = new Point(course.position);
|
||
|
||
ctx.drawGeometry(point);
|
||
|
||
if (course.id === focusedCourse) {
|
||
view.setCenter(course.position);
|
||
}
|
||
}
|
||
}
|
||
|
||
map.render();
|
||
});
|
||
|
||
map.render();
|
||
|
||
map.on("singleclick", ev => {
|
||
const mousePixel = map.getPixelFromCoordinate(ev.coordinate);
|
||
const maxDistance = sizes.courseSize + sizes.courseOuterBorder;
|
||
|
||
for (const course of Object.values(simulInstance.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) {
|
||
startFocus(course.id);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Clicking anywhere else resets focus
|
||
stopFocus();
|
||
});
|
||
|
||
return map;
|
||
};
|
||
|
||
exports.createMap = createMap;
|