require('ol/ol.css'); const axios = require('axios'); const {Map, View} = require('ol'); 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 Feature = require('ol/Feature').default; const Point = require('ol/geom/Point').default; const LineString = require('ol/geom/LineString').default; const proj = require('ol/proj'); const {Style, Fill, Stroke, Circle, Icon} = require('ol/style'); const color = require('color'); const mapboxToken = `pk.eyJ1IjoibWF0dGVvZGVsYWJyZSIsImEiOiJja2NxaTUyMmUwcmFhMn\ h0NmFsdzQ3emxqIn0.cyxF0h36emIMTk3cc4VqUw`; const simulation = require('../tam/simulation'); const network = require('../tam/network.json'); const getRouteColors = routes => { const colors = routes.filter( // Only consider normal routes (excluding alternate routes) ([lineRef, routeRef]) => network.lines[lineRef].routes[routeRef].state === 'normal' ).map(([lineRef]) => network.lines[lineRef].color); if (colors.length >= 1) { return colors; } return ['#FFFFFF']; }; const makeDataSources = () => { const segmentsSource = new VectorSource(); const stopsSource = new VectorSource(); segmentsSource.addFeatures( Object.values(network.segments).map(({routes, points}) => new Feature({ colors: getRouteColors(routes), geometry: new LineString(points.map( ({lat, lon}) => proj.fromLonLat([lon, lat]) )), }) ) ); stopsSource.addFeatures( Object.values(network.stops).map(({routes, lon, lat}) => new Feature({ colors: getRouteColors(routes), geometry: new Point(proj.fromLonLat([lon, lat])), }) ) ); return {segmentsSource, stopsSource}; }; const makeBorderColor = mainColor => { const hsl = color(mainColor).hsl(); hsl.color = Math.max(0, hsl.color[2] -= 20); return hsl.hex(); }; const makeCourseColor = mainColor => { const hsl = color(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 = color => { if (!(color in courseStyles)) { const icon = 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 (let [color, size] of [ [makeBorderColor(color), sizes.courseOuterBorder], [color, sizes.courseBorder], [makeCourseColor(color), 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[color] = new Style({ image: new Icon({ img: icon, imgSize: [icon.width, icon.height], }), }); } return courseStyles[color]; }; 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, style: segmentBorderStyle, updateWhileInteracting: true, updateWhileAnimating: true, }); const segmentsInnerLayer = new VectorLayer({ source: segmentsSource, style: segmentInnerStyle, updateWhileInteracting: true, updateWhileAnimating: true, }); const stopsLayer = new VectorLayer({ source: stopsSource, 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 focusZoom = 17; const startFocus = courseId => { if (courseId in simulInstance.courses) { const course = simulInstance.courses[courseId]; view.animate({ center: course.position, zoom: focusZoom, 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 if (stopsLayer.renderer_) { ev.context = stopsLayer.renderer_.context; ev.inversePixelTransform = stopsLayer.renderer_.inversePixelTransform; const ctx = getVectorContext(ev); let rotation = 0; for (let 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); // view.setZoom(focus); } } } map.render(); }); map.render(); map.on('singleclick', ev => { const mousePixel = map.getPixelFromCoordinate(ev.coordinate); const maxDistance = sizes.courseSize + sizes.courseOuterBorder; for (let 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;