1176 lines
No EOL
39 KiB
JavaScript
1176 lines
No EOL
39 KiB
JavaScript
/*
|
||
Timelinize
|
||
Copyright (c) 2013 Matthew Holt
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as published
|
||
by the Free Software Foundation, either version 3 of the License, or
|
||
(at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
*/
|
||
|
||
// TODO: un-global these
|
||
var mapData = {};
|
||
let coordsToItems = {};
|
||
const coordPrecision = 6;
|
||
|
||
function addMapItemToCoordinate(mapItem) {
|
||
if (mapItem.item && !coordsToItems[mapItem.coordsStr].find(elem => elem.id == mapItem.item.id)) {
|
||
coordsToItems[mapItem.coordsStr].push(mapItem.item);
|
||
}
|
||
if (mapItem.adjacent) {
|
||
for (const adjacentItem of mapItem.adjacent) {
|
||
if (adjacentItem && !coordsToItems[mapItem.coordsStr].find(elem => elem.id == adjacentItem.id)) {
|
||
coordsToItems[mapItem.coordsStr].push(adjacentItem);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const popup = new mapboxgl.Popup({
|
||
maxWidth: '247px',
|
||
closeButton: false,
|
||
closeOnClick: false,
|
||
className: 'preview'
|
||
});
|
||
|
||
async function loadAndRenderMapData() {
|
||
// like a synchronous forEach (waits for all async funcs to finish), see: https://stackoverflow.com/a/37576787/1048862
|
||
await Promise.all(tlz.openRepos.map(async repo => {
|
||
// asynchronously load and render the heatmap (query could be slow)
|
||
if (!mapData.heatmap) {
|
||
loadAndRenderHeatmap(repo);
|
||
}
|
||
|
||
const params = mapPageFilterParams(repo);
|
||
if ($('.date-input').datepicker.selectedDates.length == 0) {
|
||
const presearch = await app.SearchItems({
|
||
repo: repo.instance_id,
|
||
min_latitude: -90,
|
||
max_latitude: 90,
|
||
min_longitude: -180,
|
||
max_longitude: 180,
|
||
limit: 1,
|
||
flat: true
|
||
});
|
||
if (presearch.items && presearch.items[0]?.timestamp) {
|
||
params.start_timestamp = new Date(presearch.items[0].timestamp);
|
||
params.end_timestamp = new Date(presearch.items[0].timestamp);
|
||
params.start_timestamp.setHours(0, 0, 0, 0);
|
||
params.end_timestamp = DateTime.fromJSDate(params.start_timestamp).plus({ days: 1 }).toJSDate();
|
||
$('.date-input').datepicker.selectDate(params.start_timestamp);
|
||
}
|
||
}
|
||
const results = await app.SearchItems(params);
|
||
|
||
console.log("PARAMS AND RESULTS:", params, results);
|
||
|
||
// create list of items that can be rendered on the map; we need to isolate/count
|
||
// them so we can compute actual timeframe bounds for step colors and legend;
|
||
|
||
// prepare map data: associate each coordinate with its information & color
|
||
let newMapData = { items: [], params: params, totalDistance: 0, totalTravelTime: 0 };
|
||
for (let i = 0; i < results.items?.length; i++) {
|
||
const item = results.items[i];
|
||
|
||
if (!canRenderOnMap(item)) {
|
||
continue;
|
||
}
|
||
|
||
// confine items that are (nearly) on top of each other to the same marker;
|
||
// this is a rough approximation that doesn't work near the poles, but keeps
|
||
// items that are very close or exactly the same point from obsctructing
|
||
// others, by grouping them into the same marker
|
||
let lonStr = item.longitude.toString();
|
||
let latStr = item.latitude.toString();
|
||
const lonDigits = lonStr.indexOf('.');
|
||
const latDigits = latStr.indexOf('.');
|
||
lonStr += "0".repeat(Math.max(lonDigits+coordPrecision+1 - lonStr.length, 0))
|
||
latStr += "0".repeat(Math.max(latDigits+coordPrecision+1 - latStr.length, 0))
|
||
lonStr = lonStr.substring(0, lonDigits+coordPrecision+1);
|
||
latStr = latStr.substring(0, latDigits+coordPrecision+1);
|
||
const coordsStr = `${lonStr}, ${latStr}`;
|
||
|
||
const mapItem = {
|
||
item,
|
||
coords: [item.longitude, item.latitude],
|
||
coordsStr: coordsStr,
|
||
// use setZone: true so that the time displays in the item's time zone
|
||
timestamp: DateTime.fromISO(item.timestamp, {setZone: true}),
|
||
// we'll calculate the non-zero value for these (if it's not the first point) in a moment
|
||
travelTimeSoFar: 0,
|
||
distanceFromStart: 0
|
||
};
|
||
if (item.timespan) {
|
||
mapItem.timespan = DateTime.fromISO(item.timespan, {setZone: true})
|
||
}
|
||
|
||
// Count how much time was spent between points (as opposed to total time from
|
||
// start to end), and add up distance traveled between points. This is needed
|
||
// for proper marker and path gradient coloring. The path's gradient stops are
|
||
// set by distance, not by time; but the color represents the passage of time,
|
||
// so we need to make sure marker color and gradient stops both use the same
|
||
// points of reference from start to end. If we simply calculated end-start,
|
||
// clusters (or any points that span time) would throw off the coloring, since
|
||
// they don't cover any distance. For our purposes, we need the duration to
|
||
// cover distance, OR we need multiple gradient stops on top of each other at
|
||
// points spanning time. I think it's better to just count time and distance
|
||
// traveled.
|
||
if (newMapData.items.length > 0) {
|
||
const prev = newMapData.items[newMapData.items.length-1];
|
||
|
||
const base = newMapData.items.length == 0 ? (mapItem.timespan || mapItem.timestamp) : mapItem.timestamp;
|
||
const timeSincePrev = Math.abs(base.diff(prev.timespan || prev.timestamp).toFormat('s'));
|
||
newMapData.totalTravelTime += timeSincePrev;
|
||
mapItem.travelTimeSoFar = newMapData.totalTravelTime;
|
||
|
||
const distanceFromPrev = haversineDistance(prev.coords, mapItem.coords);
|
||
newMapData.totalDistance += distanceFromPrev;
|
||
mapItem.distanceFromStart = newMapData.totalDistance;
|
||
}
|
||
|
||
newMapData.items.push(mapItem);
|
||
}
|
||
|
||
for (let i = 0; i < newMapData.items.length; i++) {
|
||
const mapItem = newMapData.items[i];
|
||
|
||
// Hue calculation is based on simple linear y = mx+b formula,
|
||
// where y is the hue, m is the step size (hue change rate; this is
|
||
// of course "rise/run", so with hue being on our y-axis, that's "rise",
|
||
// and with time being on x-axis, that's "run", so it's hue range over
|
||
// time range), x is the current step (offset from start of timeframe)
|
||
// and b is our starting hue (y-intercept). Note that our hue range is
|
||
// NOT the difference from start to end -- that would be if we take
|
||
// the shortest path -- but actually, our gradient takes us through
|
||
// maxHue, which is 360 (or 0), so we are careful to factor that into
|
||
// our hueRange calculation; otherwise we either go out of bounds or do
|
||
// not use full range (first item should be startHue, last should be
|
||
// endHue, having gone through maxHue, or 0, along the way).
|
||
// Since we need to count DOWN from startHue to stay in range, we start at
|
||
// maxHue and subtract the current progress then add the startHue.
|
||
// i.e: "hue = maxHue - ((hueRange/timeRange) * timeOfs) + startHue" (modulo maxHue)
|
||
const startHue = 55;
|
||
const maxHue = 360;
|
||
const endHue = 240;
|
||
const hueRange = startHue + (maxHue - endHue); // distance traveled from startHue to 0/360, then from 0/360 to endHue
|
||
const scaledProgress = (hueRange / newMapData.totalTravelTime) * mapItem.travelTimeSoFar
|
||
const unboundedHue = maxHue - scaledProgress + startHue;
|
||
const hue = unboundedHue % maxHue;
|
||
const color = `hsl(${hue}deg, 100%, 55%)`;
|
||
mapItem.color = color;
|
||
|
||
makeMarker(repo, mapItem);
|
||
}
|
||
|
||
// update legend labels
|
||
if (newMapData.items.length > 0) {
|
||
// setZone: true makes the time display in the timestamp's local timezone, rather than the user's local time zone
|
||
$('.color-legend-start').innerText = (newMapData.items[0].timespan || newMapData.items[0].timestamp).toLocaleString(DateTime.DATETIME_FULL);
|
||
$('.color-legend-end').innerText = newMapData.items[newMapData.items.length-1].timestamp.toLocaleString(DateTime.DATETIME_FULL);
|
||
}
|
||
|
||
// TODO: I don't think this will work cleanly if there's more than 1 open repo
|
||
await renderMapData(newMapData);
|
||
}));
|
||
}
|
||
|
||
function makeMarker(repo, mapItem) {
|
||
const item = mapItem.item;
|
||
|
||
if (!coordsToItems[mapItem.coordsStr]) {
|
||
coordsToItems[mapItem.coordsStr] = [];
|
||
}
|
||
|
||
// check if item is only a basic location point (not a cluster),
|
||
// i.e. not an item of substance or interest
|
||
const isBasicLocation = item.classification == "location" && !item.metadata?.["Cluster size"];
|
||
|
||
// a "map item" could have two markers: one for the basic
|
||
// location dot, another a balloon for adjacent item(s)
|
||
if (!mapItem.markers) {
|
||
mapItem.markers = [];
|
||
}
|
||
|
||
// collect the items from this mapItem, and flatten them into a list for the marker
|
||
addMapItemToCoordinate(mapItem);
|
||
|
||
// if item is only a location (and is not representing a cluster of points),
|
||
// it gets only a simple colored dot since there's no real substance
|
||
if (isBasicLocation && !mapItem?.adjacent?.length) {
|
||
// if dot marker already exists, simply associate the mapItem with it and return
|
||
if (coordsToItems[mapItem.coordsStr].dotMarker) {
|
||
mapItem.markers.push(coordsToItems[mapItem.coordsStr].dotMarker);
|
||
return;
|
||
}
|
||
|
||
const markerElem = document.createElement('div');
|
||
markerElem.innerHTML = `<svg
|
||
class="location-dot"
|
||
width="15"
|
||
height="15"
|
||
viewBox="0 0 100 100"
|
||
version="1.1"
|
||
xmlns="http://www.w3.org/2000/svg">
|
||
<circle cx="50" cy="50" r="50" style="fill: ${mapItem.color};"/>
|
||
</svg>`;
|
||
coordsToItems[mapItem.coordsStr].dotMarker = new mapboxgl.Marker(markerElem).setLngLat(mapItem.coords);
|
||
mapItem.markers.push(coordsToItems[mapItem.coordsStr].dotMarker);
|
||
|
||
// show timestamp on hover
|
||
markerElem.addEventListener('mouseenter', e => {
|
||
popup.setLngLat(mapItem.coords)
|
||
// setZone: true makes the time display in the timestamp's local timezone, rather than the user's local time zone
|
||
.setHTML(`${mapItem.timestamp.toLocaleString(DateTime.DATETIME_FULL, {setZone: true})}`)
|
||
.setOffset(0)
|
||
.addTo(tlz.map);
|
||
});
|
||
markerElem.addEventListener('mouseleave', e => {
|
||
popup.remove();
|
||
});
|
||
|
||
return;
|
||
}
|
||
|
||
// show a "balloon" pin since the item is worthy of being clicked on
|
||
// (item has substance/content, or if it is a basic location, we know it
|
||
// has at least one adjacent/sidecar item to display)
|
||
|
||
// if the marker already exists, simply associate the marker with the mapItem and return
|
||
if (coordsToItems[mapItem.coordsStr].balloonMarker) {
|
||
mapItem.markers.push(coordsToItems[mapItem.coordsStr].balloonMarker);
|
||
return;
|
||
}
|
||
|
||
// the marker does not yet exist, so create it
|
||
|
||
const itemOfInterest = isBasicLocation ? mapItem.adjacent[0] : item; // TODO: marker may represent multiple geolocated items, but just choose one for an icon
|
||
const balloonColor = isBasicLocation ? 'rgb(190,190,190)' : mapItem.color;
|
||
|
||
const markerElem = cloneTemplate('#tpl-map-marker');
|
||
$('.pin', markerElem).style.fill = balloonColor;
|
||
$('.marker-icon', markerElem).innerHTML = tlz.itemClassIconAndLabel(itemOfInterest, true).icon;
|
||
|
||
coordsToItems[mapItem.coordsStr].balloonMarker = new mapboxgl.Marker({
|
||
element: markerElem,
|
||
offset: [0, -20]
|
||
}).setLngLat(mapItem.coords);
|
||
mapItem.markers.push(coordsToItems[mapItem.coordsStr].balloonMarker);
|
||
|
||
|
||
markerElem.addEventListener('click', e => {
|
||
// if already selected, simply deselect this item
|
||
if (markerElem.classList.contains('active')) {
|
||
hideMapPageInfoCard();
|
||
return;
|
||
}
|
||
|
||
// deselect any other active item and select this one
|
||
$$('.mapboxgl-marker.active').forEach(el => {
|
||
if (el == markerElem) return;
|
||
el.classList.remove('active');
|
||
});
|
||
markerElem.classList.add('active');
|
||
|
||
// fill in the preview box
|
||
$('#infocard .date').innerText = mapItem.timestamp.toLocaleString(DateTime.DATE_HUGE);
|
||
$('#infocard .time').innerText = mapItem.timestamp.toLocaleString(DateTime.TIME_WITH_SECONDS);
|
||
|
||
// display items in a timeline, careful to not use a map :)
|
||
$('#infocard .map-preview-content').replaceChildren(itemsAsTimeline(coordsToItems[mapItem.coordsStr], { noMap: true, autoplay: true }));
|
||
|
||
$('#infocard .view-details').href = `/items/${repo.instance_id}/${item.id}`;
|
||
|
||
// when ready, display!
|
||
$('#infocard').classList.remove('d-none');
|
||
|
||
tlz.map.easeTo({
|
||
center: mapItem.coords,
|
||
padding: {"left": window.innerWidth > 1000 ? $('#infocard').offsetWidth : 0 },
|
||
duration: 500
|
||
});
|
||
});
|
||
|
||
// quick preview on hover
|
||
markerElem.addEventListener('mouseenter', e => {
|
||
const itemsForPreview = coordsToItems[mapItem.coordsStr].filter(item => item.classification != 'location');
|
||
const previewEl = itemPreviews(itemsForPreview);
|
||
|
||
const containerEl = cloneTemplate('#tpl-map-popup');
|
||
const markerCoords = coordsToItems[mapItem.coordsStr].balloonMarker.getLngLat();
|
||
$('.map-preview-coordinates', containerEl).innerText = `${markerCoords.lat.toFixed(coordPrecision)}, ${markerCoords.lng.toFixed(coordPrecision)}`;
|
||
$('.map-preview-timestamp', containerEl).innerText = `${mapItem.timestamp.toLocaleString(DateTime.DATETIME_FULL)}`;
|
||
if (mapItem.timespan) {
|
||
$('.map-preview-timestamp', containerEl).innerText += `–${mapItem.timespan.toLocaleString(DateTime.DATETIME_FULL)} (${betterToHuman(mapItem.timespan.diff(mapItem.timestamp))})`;
|
||
}
|
||
if (mapItem.item?.metadata?.['Cluster size']) {
|
||
$('.map-preview-meta', containerEl).innerHTML = `<b>Cluster size:</b> ${mapItem.item?.metadata?.['Cluster size']}`;
|
||
}
|
||
containerEl.append(previewEl);
|
||
|
||
popup.setLngLat(mapItem.coords)
|
||
.setDOMContent(containerEl)
|
||
.setOffset({
|
||
'bottom': [0, -40],
|
||
'bottom-left': [0, -40],
|
||
'bottom-right': [0, -40],
|
||
'left': [15, -20],
|
||
'right': [15, -20]
|
||
})
|
||
.addTo(tlz.map);
|
||
});
|
||
markerElem.addEventListener('mouseleave', e => {
|
||
popup.remove();
|
||
});
|
||
}
|
||
|
||
async function renderMapData(newMapData) {
|
||
const dataToRender = newMapData || mapData.results;
|
||
|
||
if (!dataToRender) return;
|
||
|
||
// show basic nav controls if not already present
|
||
if (!tlz.map.hasControl(tlz.map.tl_navControl)) {
|
||
tlz.map.addControl(tlz.map.tl_navControl);
|
||
if ($('#toggle-3d').checked) {
|
||
tlz.map.dragRotate.enable();
|
||
} else {
|
||
tlz.map.dragRotate.disable();
|
||
}
|
||
}
|
||
|
||
// only draw connecting lines if it makes sense to
|
||
if (temporallyConsistent(dataToRender.params) && dataToRender.items.length) {
|
||
// connect markers with lines
|
||
// EXAMPLE: https://docs.mapbox.com/mapbox-gl-js/example/line-gradient/
|
||
const geojsonLines = {
|
||
type: 'FeatureCollection',
|
||
features: [
|
||
{
|
||
type: 'Feature',
|
||
properties: {},
|
||
geometry: {
|
||
coordinates: dataToRender.items.map(item => item.coords),
|
||
type: 'LineString'
|
||
}
|
||
}
|
||
]
|
||
};
|
||
const geojsonSymbolLines = {
|
||
type: 'FeatureCollection',
|
||
features: []
|
||
};
|
||
|
||
for (let i = 0; i < dataToRender.items.length; i++) {
|
||
const coords = [dataToRender.items[i].coords];
|
||
let duration;
|
||
if (i < dataToRender.items.length-1) {
|
||
coords.push(dataToRender.items[i+1].coords);
|
||
duration = betterToHuman(dataToRender.items[i+1].timestamp.diff(dataToRender.items[i].timestamp), { unitDisplay: 'narrow' }).replaceAll(',', '');
|
||
}
|
||
geojsonSymbolLines.features.push({
|
||
type: 'Feature',
|
||
properties: {
|
||
'label': duration
|
||
},
|
||
geometry: {
|
||
coordinates: coords,
|
||
type: 'LineString'
|
||
}
|
||
});
|
||
}
|
||
|
||
const lineGradient = function () {
|
||
let arr = []; // (gradient stop, color) pairs
|
||
for (let i = 0; i < dataToRender.items.length; i++) {
|
||
// We add a very small amount to ensure that items with equal distanceFromStart values still
|
||
// appear in a "strictly ascending order" as required for interpolation by the mapbox lib,
|
||
// even if we may very slightly overshoot 1.0.
|
||
const distanceProgress = Math.min((dataToRender.items[i].distanceFromStart / dataToRender.totalDistance) + i/1000000000, 100.0);
|
||
arr.push(distanceProgress, dataToRender.items[i].color);
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
const addSourceAndLayer = function() {
|
||
// remove previous layers/sources (remove layers first)
|
||
tlz.map.tl_removeLayer('route');
|
||
tlz.map.tl_removeLayer('symbols');
|
||
tlz.map.tl_removeSource('journey');
|
||
tlz.map.tl_removeSource('symbols-route');
|
||
|
||
tlz.map.tl_addSource('journey', {
|
||
type: 'geojson',
|
||
lineMetrics: true,
|
||
data: geojsonLines
|
||
});
|
||
tlz.map.tl_addLayer({
|
||
id: "route",
|
||
type: "line",
|
||
source: "journey",
|
||
paint: {
|
||
"line-width": 9,
|
||
'line-blur': 3,
|
||
'line-gradient': [
|
||
'interpolate',
|
||
['linear'],
|
||
['line-progress'],
|
||
...lineGradient()
|
||
]
|
||
},
|
||
layout: {
|
||
'line-cap': 'round',
|
||
'line-join': 'round'
|
||
}
|
||
});
|
||
// add text between points that label how much time between the points
|
||
tlz.map.tl_addSource('symbols-route', {
|
||
type: 'geojson',
|
||
data: geojsonSymbolLines
|
||
});
|
||
tlz.map.tl_addLayer({
|
||
"id": "symbols",
|
||
"type": "symbol",
|
||
"source": "symbols-route",
|
||
"layout": {
|
||
"symbol-placement": "line",
|
||
// "text-font": ["Open Sans Regular"],
|
||
// "text-field": 'this is a test',
|
||
"text-field": ['get', 'label'],
|
||
// "text-size": 32
|
||
},
|
||
"paint": {
|
||
"text-halo-color": "rgba(255, 255, 255, 1)",
|
||
"text-halo-width": 3
|
||
}
|
||
});
|
||
};
|
||
if (tlz.map.idle()) {
|
||
addSourceAndLayer();
|
||
} else {
|
||
tlz.map.once('idle', addSourceAndLayer);
|
||
}
|
||
}
|
||
|
||
// remove old markers, add new markers
|
||
tlz.map.tl_clearMarkers();
|
||
dataToRender.items.forEach(mapItem => mapItem.markers.forEach(marker => tlz.map.tl_addMarker(marker)));
|
||
|
||
// fly to bounding box of data if requisite
|
||
if (newMapData && dataToRender.items.length) {
|
||
let bounds = new mapboxgl.LngLatBounds();
|
||
dataToRender.items.forEach(item => bounds.extend(item.coords));
|
||
|
||
// EDIT: Scratch the below; I don't think it's accurate. The load and style.load events are actually just flaky, period.
|
||
// I can observe the flakiness even without cancelled requests.
|
||
//
|
||
// I think idle is the only reliable event.
|
||
//
|
||
// OUTDATED: ~~Any function that causes the map to move/pan/zoom/etc can cause it to cancel requests for tiles that are no longer
|
||
// necessary as part of the new camera position (at least, this is what I assume is correct behavior after observing
|
||
// network requests for .pbf files being aborted when the movement starts). We can't draw layers until the map has
|
||
// finished loading apparently, but when the tile requests are canceled, mapbox doesn't fire the load event, ever!
|
||
// (as far as I can tell) Anyway, that seems like a bug, but we can work around it by waiting until the map is idle,
|
||
// which includes movement, fade/transition animations, and tile loading. By waiting here, we can more reliably
|
||
// draw the layers like heatmap and path. Without this wait, we might cause the map to never finish loading sometimes,
|
||
// and layers randomly don't end up getting drawn.~~
|
||
const fitBounds = function() {
|
||
tlz.map.fitBounds(bounds, { padding: 100, maxZoom: 16 });
|
||
};
|
||
if (tlz.map.idle()) {
|
||
fitBounds();
|
||
} else {
|
||
tlz.map.once('idle', fitBounds); // note use of "once" instead of "on" so that we don't keep re-homing after the user pans the map or something :)
|
||
}
|
||
}
|
||
|
||
// replace the current map data with what is rendered
|
||
mapData.results = dataToRender;
|
||
|
||
|
||
if (newMapData) {
|
||
// show temporally-adjacent data - TODO: experimental! This is an attempt to show related items by temporal locality
|
||
let features = [];
|
||
await Promise.all(newMapData.items.map(async (dataPoint, idx) => {
|
||
// get timestamp of previous item and next item: the halfway point
|
||
// to either side determines our time range:
|
||
//
|
||
// [prev item]-----<min TS>-----[this item]-----<max TS>-----[next item]
|
||
let prevTs = newMapData.items?.[idx - 1]?.item.timestamp;
|
||
let nextTs = newMapData.items?.[idx + 1]?.item.timestamp;
|
||
|
||
// if we're at one end of the list, just use the current
|
||
// timestamp for the other side
|
||
prevTs = prevTs || dataPoint.item.timestamp;
|
||
nextTs = nextTs || dataPoint.item.timestamp;
|
||
|
||
const dt = DateTime.fromISO(dataPoint.item.timestamp, { setZone: true });
|
||
|
||
// as a last resort, choose an arbitrary span of time if that's all we can do;
|
||
// and convert to luxon DT values so we can split the differences...
|
||
let prevDT = prevTs ? DateTime.fromISO(prevTs, { setZone: true }) : dt.minus({ minutes: 30 });
|
||
let nextDT = nextTs ? DateTime.fromISO(nextTs, { setZone: true }) : dt.plus({ minutes: 30 });
|
||
|
||
// if location data is too far apart in time, arbitrarily cap the time span
|
||
prevDT = DateTime.max(prevDT, dt.minus({ hours: 12 }));
|
||
nextDT = DateTime.min(nextDT, dt.plus({ hours: 12 }));
|
||
|
||
// we're looking for the min and max timestamp for our search, which is
|
||
// halfway the time to either prev or next item
|
||
const itvlFullBefore = Interval.fromDateTimes(DateTime.min(prevDT, dt), DateTime.max(prevDT, dt));
|
||
const itvlFullAfter = Interval.fromDateTimes(DateTime.min(dt, nextDT), DateTime.max(dt, nextDT));
|
||
const minDT = itvlFullBefore.divideEqually(2)[1]?.start || dt;
|
||
const maxDT = itvlFullAfter.divideEqually(2)[0]?.end || dt;
|
||
|
||
const results = await app.SearchItems({
|
||
repo: tlz.openRepos[0].instance_id,
|
||
// TODO: we need a way to search for entity IDs involved regardless of how they're involved (entity_id, from_entity_id, to_entity_id...)
|
||
// entity_id: [dataPoint.item.entity.id],
|
||
// to_entity_id: [dataPoint.item.entity.id],
|
||
start_timestamp: minDT.toISO(),
|
||
end_timestamp: maxDT.toISO(),
|
||
no_location: true,
|
||
limit: 1 // TODO: what should this be?
|
||
});
|
||
dataPoint.adjacent = results.items;
|
||
|
||
if (!dataPoint.adjacent?.length) {
|
||
return;
|
||
}
|
||
|
||
// augment known item(s) with new adjacent items
|
||
addMapItemToCoordinate(dataPoint);
|
||
|
||
const moreDotElem = $('.more-dot', dataPoint.markers[0].getElement());
|
||
if (moreDotElem) {
|
||
// marker is a balloon pin with a dot we can simply show
|
||
moreDotElem.classList.remove('d-none');
|
||
} else {
|
||
// marker is just a location dot, so we can't really show a dot over a dot;
|
||
// instead, add a new gray balloon to this point
|
||
makeMarker(tlz.openRepos[0], dataPoint);
|
||
|
||
// show the new marker on the map
|
||
tlz.map.tl_addMarker(dataPoint.markers[dataPoint.markers.length-1]);
|
||
}
|
||
|
||
}));
|
||
|
||
|
||
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function updateMapLighting() {
|
||
const currentHour = new Date().getHours();
|
||
if (currentHour >= 5 && currentHour < 9) {
|
||
tlz.map.setConfigProperty('basemap', 'lightPreset', 'dawn');
|
||
} else if (currentHour >= 9 && currentHour < 18) {
|
||
tlz.map.setConfigProperty('basemap', 'lightPreset', 'day');
|
||
} else if (currentHour >= 18 && currentHour < 21) {
|
||
tlz.map.setConfigProperty('basemap', 'lightPreset', 'dusk');
|
||
} else if (currentHour >= 21 || currentHour < 5) {
|
||
tlz.map.setConfigProperty('basemap', 'lightPreset', 'night');
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// TODO: this adds buildings, if we want that...
|
||
function addBuildings() {
|
||
// Insert the layer beneath any symbol layer.
|
||
const layers = tlz.map.getStyle().layers;
|
||
const labelLayerId = layers.find(
|
||
(layer) => layer.type === 'symbol' && layer.layout['text-field']
|
||
).id;
|
||
|
||
// The 'building' layer in the Mapbox Streets
|
||
// vector tileset contains building height data
|
||
// from OpenStreetMap.
|
||
tlz.map.tl_addLayer(
|
||
{
|
||
'id': 'add-3d-buildings',
|
||
'source': 'composite',
|
||
'source-layer': 'building',
|
||
'filter': ['==', 'extrude', 'true'],
|
||
'type': 'fill-extrusion',
|
||
'minzoom': 15,
|
||
'paint': {
|
||
'fill-extrusion-color': '#aaa',
|
||
|
||
// Use an 'interpolate' expression to
|
||
// add a smooth transition effect to
|
||
// the buildings as the user zooms in.
|
||
'fill-extrusion-height': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
15, 0, 15.05,
|
||
['get', 'height']
|
||
],
|
||
'fill-extrusion-base': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
15, 0, 15.05,
|
||
['get', 'min_height']
|
||
],
|
||
'fill-extrusion-opacity': 0.6
|
||
}
|
||
},
|
||
labelLayerId
|
||
);
|
||
}
|
||
|
||
function applyTerrain(changed3D) {
|
||
if ($('#toggle-3d')?.checked) {
|
||
tlz.map.setTerrain({
|
||
source: 'mapbox-dem',
|
||
exaggeration: 1.0
|
||
});
|
||
if (changed3D) {
|
||
tlz.map.dragRotate.enable();
|
||
}
|
||
} else {
|
||
tlz.map.setTerrain();
|
||
if (changed3D) {
|
||
tlz.map.dragRotate.disable();
|
||
tlz.map.easeTo({
|
||
duration: 1000,
|
||
bearing: 0,
|
||
pitch: 0,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
on('change', '#toggle-3d', event => {
|
||
applyTerrain(true);
|
||
});
|
||
|
||
|
||
// TODO: Would love to make box zooming simpler. See https://github.com/mapbox/mapbox-gl-js/issues/12405
|
||
|
||
on('click', '#bbox-toggle', event => {
|
||
if ($('#bbox-toggle').classList.contains('active')) {
|
||
// disable panning and box zooming (so as not to conflict with our custom box drawing)
|
||
tlz.map.boxZoom.disable();
|
||
tlz.map.dragPan.disable();
|
||
|
||
// show useful cursor indicative of creating a box
|
||
$('.mapboxgl-canvas-container').style.cursor = 'crosshair';
|
||
|
||
// when user clicks down on map, start drawing process
|
||
tlz.map.on('mousedown', onMouseDown);
|
||
|
||
const canvas = tlz.map.getCanvasContainer();
|
||
|
||
// Variable to hold the starting xy point and
|
||
// coordinates when mousedown occured.
|
||
let startPt, startCoord;
|
||
|
||
// Variable for the draw box element.
|
||
let box;
|
||
|
||
function onMouseDown(e) {
|
||
// TODO: if space is held down (keyCode 32 -- probably needed in keyDown event), don't draw the box: pan instead
|
||
|
||
tlz.map.on('mousemove', onMouseMove);
|
||
tlz.map.on('mouseup', onMouseUp);
|
||
|
||
// for cancellation
|
||
document.addEventListener('keydown', onKeyDown);
|
||
|
||
// Capture the first xy coordinates
|
||
startPt = e.point;
|
||
startCoord = e.lngLat;
|
||
}
|
||
|
||
function onMouseMove(e) {
|
||
// Capture the ongoing xy coordinates
|
||
let current = e.point;
|
||
|
||
if (!box) {
|
||
box = document.createElement('div');
|
||
box.classList.add('boxdraw');
|
||
canvas.appendChild(box);
|
||
}
|
||
|
||
const minX = Math.min(startPt.x, current.x),
|
||
maxX = Math.max(startPt.x, current.x),
|
||
minY = Math.min(startPt.y, current.y),
|
||
maxY = Math.max(startPt.y, current.y);
|
||
|
||
// Adjust width and xy position of the box element ongoing
|
||
const pos = `translate(${minX}px, ${minY}px)`;
|
||
box.style.transform = pos;
|
||
box.style.width = maxX - minX + 'px';
|
||
box.style.height = maxY - minY + 'px';
|
||
}
|
||
|
||
function onMouseUp(e) {
|
||
finish([startCoord, e.lngLat]);
|
||
}
|
||
|
||
function onKeyDown(e) {
|
||
if (e.keyCode === 27) finish();
|
||
}
|
||
|
||
function finish(bbox) {
|
||
// remove the box polygon (literally "poly-gone")
|
||
if (box) {
|
||
box.remove();
|
||
box = null;
|
||
}
|
||
|
||
if (bbox) {
|
||
const bboxEl = $('#bbox');
|
||
bboxEl.value = `(${bbox[0].lat.toFixed(4)}, ${bbox[0].lng.toFixed(4)}) (${bbox[1].lat.toFixed(4)}, ${bbox[1].lat.toFixed(4)})`
|
||
bboxEl.dataset.lat1 = bbox[0].lat;
|
||
bboxEl.dataset.lon1 = bbox[0].lng;
|
||
bboxEl.dataset.lat2 = bbox[1].lat;
|
||
bboxEl.dataset.lon2 = bbox[1].lng;
|
||
trigger('#bbox', 'change');
|
||
}
|
||
|
||
tlz.map.dragPan.enable();
|
||
tlz.map.boxZoom.enable();
|
||
|
||
tlz.map.off('mousedown', onMouseDown);
|
||
tlz.map.off('mousemove', onMouseMove);
|
||
tlz.map.off('mouseup', onMouseUp);
|
||
|
||
$('.mapboxgl-canvas-container').style.cursor = '';
|
||
|
||
$('#bbox-toggle').classList.remove('active');
|
||
}
|
||
}
|
||
});
|
||
|
||
// TODO: This is too "eager" in my experience on macOS
|
||
// // when clicking "off" a marker, un-classify it as active and hide item info
|
||
// on('click', '#map-page #map', event => {
|
||
// if (event.target.closest('.mapboxgl-marker')) return;
|
||
// hideMapPageInfoCard();
|
||
// });
|
||
|
||
on('click', '#map-page #infocard .btn-action.close', e => {
|
||
hideMapPageInfoCard();
|
||
});
|
||
|
||
function hideMapPageInfoCard() {
|
||
tlz.map.easeTo({
|
||
padding: { left: 0 },
|
||
duration: 500
|
||
});
|
||
$('#infocard').classList.add('d-none');
|
||
$$('.mapboxgl-marker.active').forEach(el => {
|
||
el.classList.remove('active');
|
||
});
|
||
}
|
||
|
||
on('click', '#proximity-toggle', event => {
|
||
$('.mapboxgl-canvas-container').style.cursor =
|
||
$('#proximity-toggle').classList.contains('active') ? 'crosshair' : '';
|
||
});
|
||
|
||
on('change', '#bbox', e => {
|
||
if (e.target.value) {
|
||
$('#bbox-clear').classList.remove('d-none');
|
||
} else {
|
||
$('#bbox-clear').classList.add('d-none');
|
||
}
|
||
});
|
||
|
||
on('keyup, paste', '#proximity', e => {
|
||
if (!e.target.value) {
|
||
delete e.target.dataset.lat;
|
||
delete e.target.dataset.lon;
|
||
e.target.classList.remove('is-invalid');
|
||
return;
|
||
}
|
||
const regex = /(-?\d*(\.\d+)?),\s*(-?\d*(\.\d+)?)/;
|
||
const matches = regex.exec(e.target.value);
|
||
if (matches && matches.length > 2) {
|
||
// parsed (lat,lon) is available in matches[1] and matches[3]
|
||
try {
|
||
if ((Number(matches[1]) <= 90 || Number(matches[1]) >= -90)
|
||
&& (Number(matches[3] <= 180) || Number(matches[3] >= 180))) {
|
||
e.target.classList.remove('is-invalid');
|
||
e.target.dataset.lat = matches[1];
|
||
e.target.dataset.lon = matches[3];
|
||
} else {
|
||
throw "out of bounds";
|
||
}
|
||
} catch {
|
||
e.target.classList.remove('is-invalid');
|
||
}
|
||
} else {
|
||
e.target.classList.add('is-invalid');
|
||
delete e.target.dataset.lat;
|
||
delete e.target.dataset.lon;
|
||
}
|
||
});
|
||
|
||
|
||
on('change', 'input[name=map-style]', e => {
|
||
tlz.map.setStyle('mapbox://styles/mapbox/' + e.target.value);
|
||
});
|
||
|
||
|
||
on('click', '#bbox-clear', event => {
|
||
const bboxEl = $('#bbox');
|
||
bboxEl.value = '';
|
||
delete bboxEl.dataset.lat1;
|
||
delete bboxEl.dataset.lat2;
|
||
delete bboxEl.dataset.lon1;
|
||
delete bboxEl.dataset.lon2;
|
||
trigger('#bbox', 'change');
|
||
});
|
||
|
||
|
||
|
||
// noUiSlider.create($('#range-nearby'), {
|
||
// start: 0,
|
||
// connect: [true, false],
|
||
// // step: 10,
|
||
// range: {
|
||
// min: 0,
|
||
// max: 100
|
||
// }
|
||
// });
|
||
|
||
|
||
|
||
|
||
|
||
function mapPageFilterParams(repo) {
|
||
const params = {
|
||
repo: repo.instance_id,
|
||
min_latitude: -90,
|
||
max_latitude: 90,
|
||
min_longitude: -180,
|
||
max_longitude: 180,
|
||
related: 1,
|
||
limit: 500, // TODO: ... paginate?
|
||
flat: true,
|
||
relations: [
|
||
// don't show motion pictures / live photos, since they are not
|
||
// considered their own item in a gallery sense, and perhaps
|
||
// more importantly, we don't want to have to generate a thumbnail
|
||
// for them (literally no need for a thumbnail of those, just
|
||
// wasted CPU time and storage space)
|
||
{
|
||
"not": true,
|
||
"relation_label": "motion"
|
||
}
|
||
]
|
||
};
|
||
commonFilterSearchParams(params);
|
||
|
||
// This makes the Date object be interpreted as UTC time, not local time, i.e. "15:00" in -6 GMT will be shifted to "21:00" with no TZ offset.s
|
||
// Same thing as Luxon's ".toUTC(null, { keepLocalTime: true })".
|
||
function reinterpretAsUTC(localDate) {
|
||
// Shift by the local offset so wall time = UTC time
|
||
return new Date(localDate.getTime() - localDate.getTimezoneOffset() * 60_000);
|
||
}
|
||
|
||
|
||
// date range
|
||
// if one date is selected, set the range to that day;
|
||
// if two dates are selected, make the range inclusive
|
||
const pickerDates = $('.date-input').datepicker.selectedDates;
|
||
if (pickerDates.length > 0) {
|
||
params.start_timestamp = reinterpretAsUTC(pickerDates[0])
|
||
params.end_timestamp = DateTime.fromJSDate(pickerDates[pickerDates.length-1]).toUTC(null, { keepLocalTime: true }).plus({ days: 1 }).toJSDate();
|
||
}
|
||
|
||
// location proximity
|
||
const prox = $('#proximity');
|
||
if (prox.dataset.lat) {
|
||
params.latitude = Number(prox.dataset.lat);
|
||
}
|
||
if (prox.dataset.lon) {
|
||
params.longitude = Number(prox.dataset.lon);
|
||
}
|
||
if (prox.dataset.lat || prox.dataset.lon) {
|
||
// TODO: is this the right behavior? Proximity search should probably be regardless of date picker, right?
|
||
delete params.start_timestamp;
|
||
delete params.end_timestamp;
|
||
|
||
params.limit = 10;
|
||
}
|
||
|
||
// bounding box
|
||
const bbox = $('#bbox');
|
||
if (bbox.dataset.lat1 && bbox.dataset.lat2 && bbox.dataset.lon1 && bbox.dataset.lon2) {
|
||
params.min_latitude = Math.min(Number(bbox.dataset.lat1), Number(bbox.dataset.lat2));
|
||
params.max_latitude = Math.max(Number(bbox.dataset.lat1), Number(bbox.dataset.lat2));
|
||
params.min_longitude = Math.min(Number(bbox.dataset.lon1), Number(bbox.dataset.lon2));
|
||
params.max_longitude = Math.max(Number(bbox.dataset.lon1), Number(bbox.dataset.lon2));
|
||
|
||
// TODO: same with bounding box as with proximity, right? ignore date picker?
|
||
delete params.start_timestamp;
|
||
delete params.end_timestamp;
|
||
|
||
params.limit = 10;
|
||
}
|
||
|
||
console.log("PARAMS:", params)
|
||
|
||
return params;
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// temporallyConsistent returns true if searchParams is expected to have temporal locality,
|
||
// meaning that the search results can be expected to be adjacent to each other in time; i.e.
|
||
// if true, then it may make sense to connect the search results by a line, for example,
|
||
// because they are likely to be adjacent in time. If search results are confined to a
|
||
// particular spatial (spacial?) region, however, we don't know what points along the time
|
||
// continuum may be missing, so it wouldn't make sense to connect them as if someone or
|
||
// something went directly from place to place.
|
||
//
|
||
// Parameters are considered temporally consistent if they do not constrain by a spatial
|
||
// bounding box or a spatial proximity; i.e. min/max_lat/lon and lat/lon must be empty
|
||
// (or the min/max fields can be set to the full range of lat/lon values, which is useful
|
||
// when querying for geolocated items regardless of location).
|
||
function temporallyConsistent(searchParams) {
|
||
return ((!searchParams.min_latitude && !searchParams.max_latitude
|
||
&& !searchParams.min_longitude && !searchParams.max_longitude)
|
||
|| (searchParams.min_latitude <= -90 && searchParams.max_latitude >= 90
|
||
&& searchParams.min_longitude <= -180 && searchParams.max_longitude >= 180))
|
||
&& !searchParams.latitude && !searchParams.longitude;
|
||
}
|
||
|
||
|
||
|
||
function canRenderOnMap(item) {
|
||
// explicitly check whether defined, since 0,0 is a valid coordinate
|
||
return item.latitude !== undefined || item.longitude !== undefined;
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function loadAndRenderHeatmap(repo) {
|
||
// first, get the count of how many points we should expect
|
||
// (we can speed this up very drastically by sampling the
|
||
// rows and multiplying the count by the sample interval
|
||
// to get close to the true count)
|
||
const intervalForCount = 1000;
|
||
const params = {
|
||
repo: repo.instance_id,
|
||
min_latitude: -90,
|
||
max_latitude: 90,
|
||
min_longitude: -180,
|
||
max_longitude: 180,
|
||
geojson: true,
|
||
limit: 1, // will discard; we just want count first
|
||
sample: intervalForCount,
|
||
with_total: true,
|
||
flat: true
|
||
};
|
||
|
||
app.SearchItems(params).then(results => {
|
||
// now that we have the count, update the search parameters
|
||
// until we get more controls over the heatmap implemented, set
|
||
// an approximate hard-limit on the number of items the heatmap
|
||
// is comprised of to keep performance of the map decent
|
||
const desiredItemCount = 100000;
|
||
params.sample = Math.ceil(results.total*intervalForCount / desiredItemCount);
|
||
params.limit = -1;
|
||
delete params.with_total;
|
||
|
||
app.SearchItems(params).then(results => {
|
||
mapData.heatmap = JSON.parse(results.geojson);
|
||
renderHeatmap();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderHeatmap() {
|
||
const addSourceAndLayer = function() {
|
||
tlz.map.tl_removeLayer('items-sample'); // always remove layers before sources, which the layers depend on
|
||
tlz.map.tl_removeSource('all-items');
|
||
|
||
tlz.map.tl_addSource('all-items', {
|
||
type: 'geojson',
|
||
data: mapData.heatmap
|
||
});
|
||
// Docs: https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/#heatmap
|
||
tlz.map.tl_addLayer({
|
||
'id': 'items-sample',
|
||
'type': 'heatmap',
|
||
'source': 'all-items',
|
||
// 'maxzoom': 16,
|
||
|
||
// The heatmap paint properties have been carefully tuned over hours of various
|
||
// data sets to try to achieve an optimal viewing experience with the most useful
|
||
// color presentation. It's very difficult to tune well. I find it helpful to
|
||
// revert to static values and adjust interpolated properties one at a time for
|
||
// tuning. Get familiar with exactly how each property behaves/influences the
|
||
// result.
|
||
'paint': {
|
||
// The weight each point carries. Tuned in coordination with the color
|
||
// settings. When zoomed out, points appear closer together, so it's
|
||
// easier to get to red/hot blobs, so a lower weight is used. But if
|
||
// we go too low (i.e. .001), the gaussian algorithm starts to render
|
||
// a truncated/crosshatched/blotchy heatmap. This value is scaled by
|
||
// the intensity property. Currently, we increase weight when zoomed
|
||
// in since points will spread out more, and we want individual points
|
||
// in sparse areas to still show up, but when zoomed out, we don't
|
||
// want everything to be a red blob (even though most cities/clusters
|
||
// will be red anyway). As we zoom in, we want the red to dissipate
|
||
// quickly to be red/hot only over actual clusters.
|
||
'heatmap-weight': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
1, 0.01,
|
||
5, 0.015,
|
||
10, 0.02,
|
||
20, 0.025,
|
||
],
|
||
|
||
// Radius adjusts the spread of influence of each point's weight.
|
||
// Too high, and you'll have hot blobs everywhere. Too low, and
|
||
// you won't be able to see individual points or even small clusters.
|
||
// A lower radius is very helpful at lower zoom levels to avoid
|
||
// every path looking like a red blob.
|
||
'heatmap-radius': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
1, 5,
|
||
10, 25,
|
||
14, 40,
|
||
18, 75,
|
||
],
|
||
|
||
// Intensity apparently scales the weight. Default is 1 (no scaling).
|
||
// When zoomed out, lots of points are already going to be close together,
|
||
// so we desensitize the weight, so that not everything is a red blob when
|
||
// looking at the big picture. When zoomed in, we increase the intensity
|
||
// so that singular/sparse points don't get lost. But once we get REALLY
|
||
// zoomed in, we don't need that intensity as the user isn't scanning or
|
||
// searching at that point, they're just trying to drill down.
|
||
'heatmap-intensity': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
1, 0.2,
|
||
5, 0.5,
|
||
9, 1,
|
||
12, 4,
|
||
14, 4,
|
||
18, 2
|
||
],
|
||
|
||
// Tune colors relative to weight so that red appears only for absolute hotspots.
|
||
// Input range (density) is 0-1. This is the only property that can use density
|
||
// as an input.
|
||
'heatmap-color': [
|
||
'interpolate', ['linear'], ['heatmap-density'],
|
||
0, 'rgba(0, 0, 0, 0)',
|
||
0.015, 'royalblue', // don't raise this too much relative to weight*intensity, or the lone points will disappear
|
||
0.1, 'cyan',
|
||
0.12, 'lime',
|
||
0.4, 'yellow',
|
||
1, 'red'
|
||
],
|
||
|
||
// Opacity could be interpolated based on zoom level as well,
|
||
// but so far I've found a static value to offer good visibility
|
||
// at all zoom levels. Zoomed out, less opaque is okay since you
|
||
// can't see details anyway, but zoomed in you don't want to go
|
||
// too transparent or you lose the singular points, which may
|
||
// still be desired. So I just choose this middle ground for now.
|
||
'heatmap-opacity': .5
|
||
}
|
||
}, 'route');
|
||
};
|
||
// source can't be added until style loads, apparently: https://stackoverflow.com/q/40557070 (and layer depends on source)
|
||
if (tlz.map.idle()) {
|
||
addSourceAndLayer();
|
||
} else {
|
||
tlz.map.once('idle', addSourceAndLayer);
|
||
}
|
||
}
|
||
|
||
|
||
// Returns the distance between two coordinates in kilometers.
|
||
// from https://stackoverflow.com/a/48805273/1048862
|
||
// NOTE: We use [lon, lat] order because that's what Mapbox uses, so the rest of our code uses this order too.
|
||
function haversineDistance([lon1, lat1], [lon2, lat2], miles = false) {
|
||
const toRadian = angle => (Math.PI / 180) * angle;
|
||
const distance = (a, b) => (Math.PI / 180) * (a - b);
|
||
const RADIUS_OF_EARTH_IN_KM = 6371;
|
||
|
||
const dLat = distance(lat2, lat1);
|
||
const dLon = distance(lon2, lon1);
|
||
|
||
lat1 = toRadian(lat1);
|
||
lat2 = toRadian(lat2);
|
||
|
||
// Haversine Formula
|
||
const a =
|
||
Math.pow(Math.sin(dLat / 2), 2) +
|
||
Math.pow(Math.sin(dLon / 2), 2) * Math.cos(lat1) * Math.cos(lat2);
|
||
const c = 2 * Math.asin(Math.min(1, Math.sqrt(a))); // min() protects against roundoff errors the two points are nearly antipodal
|
||
|
||
let finalDistance = RADIUS_OF_EARTH_IN_KM * c;
|
||
|
||
if (miles) {
|
||
finalDistance /= 1.60934;
|
||
}
|
||
|
||
return finalDistance;
|
||
} |