/*
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 .
*/
// 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 = ``;
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 = `Cluster size: ${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]----------[this item]----------[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;
}