digitransit-ui/app/component/map/tile-layer/TileContainer.js
2025-07-03 14:25:08 +03:00

304 lines
8.9 KiB
JavaScript

import flatten from 'lodash/flatten';
import omit from 'lodash/omit';
import L from 'leaflet';
import { isEqual } from 'lodash';
import { isLayerEnabled } from '../../../util/mapLayerUtils';
import { getStopIconStyles } from '../../../util/mapIconUtils';
import { getVehicleMinZoomOnStopsNearYou } from '../../../util/vehicleRentalUtils';
import events from '../../../util/events';
class TileContainer {
constructor(
coords,
done,
props,
config,
mergeStops,
relayEnvironment,
highlightedStops,
vehicles,
stopsToShow,
objectsToHide,
lang,
) {
const markersMinZoom = Math.min(
getVehicleMinZoomOnStopsNearYou(
config,
props.mapLayers.citybikeOverrideMinZoom,
),
config.stopsMinZoom,
config.terminalStopsMinZoom,
);
this.coords = coords;
this.mergeStops = mergeStops;
this.props = props;
this.extent = 4096;
this.scaleratio = window.devicePixelRatio || 1;
this.tileSize = (this.props.tileSize || 256) * this.scaleratio;
this.ratio = this.extent / this.tileSize;
this.el = this.createElement();
this.clickCount = 0;
this.highlightedStops = highlightedStops;
this.vehicles = vehicles;
this.stopsToShow = stopsToShow;
this.objectsToHide = objectsToHide;
if (events.listenerCount('vehiclesChanged') === 0) {
events.on('vehiclesChanged', this.onVehiclesChange);
}
let ignoreMinZoomLevel =
highlightedStops &&
highlightedStops.length > 0 &&
!highlightedStops.every(stop => stop === '');
if (vehicles && vehicles.length > 0) {
ignoreMinZoomLevel = vehicles.every(
v => v.mode === 'ferry' && v.mode === 'rail' && v.mode === 'subway',
);
}
if (
(!ignoreMinZoomLevel && this.coords.z < markersMinZoom) ||
!this.el.getContext
) {
setTimeout(() => done(null, this.el), 0);
return;
}
this.ctx = this.el.getContext('2d');
this.layers = this.props.layers
.filter(Layer => {
const layerName = Layer.getName();
// stops and terminals are drawn on same layer
const isEnabled =
isLayerEnabled(layerName, this.props.mapLayers) ||
(layerName === 'stop' &&
isLayerEnabled('terminal', this.props.mapLayers));
if (
layerName === 'stop' &&
(ignoreMinZoomLevel ||
this.coords.z >= config.stopsMinZoom ||
this.coords.z >= config.terminalStopsMinZoom)
) {
return isEnabled;
}
if (
(layerName === 'citybike' || layerName === 'scooter') &&
this.coords.z >=
getVehicleMinZoomOnStopsNearYou(
config,
props.mapLayers.citybikeOverrideMinZoom,
)
) {
return isEnabled;
}
if (
(layerName === 'parkAndRide' ||
layerName === 'parkAndRideForBikes') &&
config.parkAndRide &&
this.coords.z >= config.parkAndRide.parkAndRideMinZoom
) {
return isEnabled;
}
return false;
})
.map(
Layer =>
new Layer(
this,
config,
this.props.mapLayers,
relayEnvironment,
mergeStops,
),
);
this.el.layers = this.layers.map(layer => omit(layer, 'tile'));
Promise.all(this.layers.map(layer => layer.getPromise(lang))).then(() =>
done(null, this.el),
);
}
onVehiclesChange = vehicles => {
if (!isEqual(this.vehicles, vehicles)) {
this.vehicles = { ...vehicles };
}
};
project = point => {
const size =
this.extent * 2 ** (this.coords.z + (this.props.zoomOffset || 0));
const x0 = this.extent * this.coords.x;
const y0 = this.extent * this.coords.y;
const y1 = 180 - ((point.y + y0) * 360) / size;
return {
lon: ((point.x + x0) * 360) / size - 180,
lat: (360 / Math.PI) * Math.atan(Math.exp(y1 * (Math.PI / 180))) - 90,
};
};
latLngToPoint = (lat, lon) => {
const size =
this.extent * 2 ** (this.coords.z + (this.props.zoomOffset || 0));
const x0 = this.extent * this.coords.x;
const y0 = this.extent * this.coords.y;
const x = ((lon + 180) * size) / 360;
const pointX = x - x0;
const y1 =
180 * (Math.log(Math.tan(((lat + 90) * Math.PI) / 360)) / Math.PI);
const y2 = Math.abs((y1 - 180) * size) / 360;
const pointY = y2 - y0;
return { x: pointX, y: pointY };
};
createElement = () => {
const el = document.createElement('canvas');
el.setAttribute('class', 'leaflet-tile');
el.setAttribute('height', this.tileSize);
el.setAttribute('width', this.tileSize);
el.onMapClick = this.onMapClick;
return el;
};
onMapClick = (e, point) => {
let nearest;
let features;
let localPoint;
const vehicleKeys = Object.keys(this.vehicles);
const projectedVehicles = vehicleKeys.map(key => {
const vehicle = this.vehicles[key];
const pointGeom = this.latLngToPoint(vehicle.lat, vehicle.long);
return {
layer: 'realTimeVehicle',
feature: { geom: pointGeom, vehicle, properties: {} },
};
});
if (this.layers) {
localPoint = [
(point[0] * this.scaleratio) % this.tileSize,
(point[1] * this.scaleratio) % this.tileSize,
];
features = flatten(
this.layers.map(
layer =>
layer.features &&
layer.features.map(feature => ({
layer: layer.constructor.getName(),
feature,
})),
),
);
features = projectedVehicles.concat(features);
nearest = features.filter((feature, index) => {
if (!feature) {
return false;
}
const g = feature.feature.geom;
// collision check for stops and citybike stations is different for different icons which depend on zoom level
const featureX = g.x / this.ratio;
let featureY = g.y / this.ratio;
let isCombo = false;
let secondY;
if (
(feature.layer === 'stop' && !feature.feature.properties.stops) ||
feature.layer === 'citybike' ||
feature.layer === 'scooter'
) {
const zoom = this.coords.z;
// hitbox is same for stop and citybike
const iconStyles = getStopIconStyles('stop', zoom);
if (iconStyles) {
const { style } = iconStyles;
let { height, width } = iconStyles;
width *= this.scaleratio;
height *= this.scaleratio;
const circleRadius = width / 2;
if (style === 'large' || feature.layer === 'realTimeVehicle') {
featureY -= height - circleRadius;
}
// combo stops have a larger hitbox that is not circular
// use two points for collision detection, lower and upper center of icon
// features array is sorted by y coord so combo stops should be next to each other
if (
index > 0 &&
features[index - 1]?.feature.properties.code ===
feature.feature.properties.code
) {
isCombo = true;
}
if (
index < features.length - 1 &&
features[index + 1]?.feature.properties.code ===
feature.feature.properties.code
) {
isCombo = true;
}
if (isCombo && style === 'large') {
secondY = featureY - width;
}
}
}
let dist = Math.sqrt(
(localPoint[0] - featureX) ** 2 + (localPoint[1] - featureY) ** 2,
);
if (isCombo) {
dist = Math.min(
dist,
Math.sqrt(
(localPoint[0] - featureX) ** 2 + (localPoint[1] - secondY) ** 2,
),
);
}
if (dist < 22 * this.scaleratio) {
return true;
}
return false;
});
if (nearest.length === 0 && e.type === 'click') {
// Must filter double clicks used for map navigation
if (!this.timer) {
this.timer = setTimeout(() => {
this.timer = null;
return this.onSelectableTargetClicked([], e.latlng);
}, 300);
} else {
clearTimeout(this.timer);
this.timer = null;
}
return false;
}
if (nearest.length === 0 && e.type === 'contextmenu') {
// no need to check double clicks
return this.onSelectableTargetClicked([], e.latlng);
}
if (nearest.length === 1) {
L.DomEvent.stopPropagation(e);
// open menu for single stop
const latLon = L.latLng(this.project(nearest[0].feature.geom));
return this.onSelectableTargetClicked(nearest, latLon, true);
}
L.DomEvent.stopPropagation(e);
return this.onSelectableTargetClicked(nearest, e.latlng, true); // open menu for a list of stops
}
return false;
};
}
export default TileContainer;