import {rectangle, rearrange, bruteForceIntersections, lineIntersecions} from "./rectangle.js";
// headings of coordinates csv
var headings = ["index","shape","city_latitude","city_longitude"]
// dialect for the csv library
var csvDialect = {
"dialect": {
"csvddfVersion": 1.2,
"delimiter": ";",
"doubleQuote": true,
"lineTerminator": "\r\n",
"quoteChar": "\"",
"skipInitialSpace": true,
"header": true,
"commentChar": "#"
}
}
// the default icon
var defaultIcon = {}
// paths to icons
const iconPaths = {
"light": "shapes/light.svg",
"triangle": "shapes/triangle.svg",
"circle": "shapes/circle.svg",
"unknown": "shapes/default.svg",
"oval": "shapes/oval.svg",
"other": "shapes/default.svg",
"formation": "shapes/formation.svg",
"sphere": "shapes/sphere.svg",
"diamond": "shapes/diamond.svg",
"flash": "shapes/flash.svg",
"disk": "shapes/disk.svg",
"delta": "shapes/delta.svg",
"cigar": "shapes/cigar.svg",
"fireball": "shapes/fireball.svg",
"changing": "shapes/changing.svg",
"rectangle": "shapes/rectangle.svg",
"egg": "shapes/egg.svg",
"chevron": "shapes/delta.svg",
"cross": "shapes/cross.svg",
"cylinder": "shapes/cylinder.svg",
"cone": "shapes/cone.svg",
"teardrop": "shapes/teardrop.svg"
}
// colors for the icon lines
const colors = ['#8dd3c7','#ffffb3','#bebada','#fb8072','#80b1d3','#fdb462','#b3de69','#fccde5','#d9d9d9','#bc80bd','#ccebc5','#ffed6f', '#aaaaaa'];
const iconColors = {
"light": 1,
"triangle": 5,
"circle": 3,
"unknown": 12,
"oval": 3,
"other": 12,
"formation": 10,
"sphere": 3,
"diamond": 7,
"flash": 11,
"disk": 3,
"delta": 1,
"cigar": 2,
"fireball": 3,
"changing": 0,
"rectangle": 7,
"egg": 8,
"chevron": 1,
"cross": 5,
"cylinder": 6,
"cone": 4,
"teardrop": 0
}
// max number of popups to show
const MAX_RECTS = 50;
// global vars
var icons = {}
var redIcon = {}
var map = {}
var rects = [] // all loaded rectangles
// layers for the map
var detailsLayer = {}
var linesLayer = {}
var layerControl = {}
var cluster = {}
var dataCount = 0;
var loading = {}; // the loading div
var isLoading = false;
function startLoading(){
loading.style.visibility = "visible";
isLoading = true;
}
function stopLoading(){
loading.style.visibility = "collapse";
isLoading = false;
}
/**
* Creates the leaflet icons for all the defined shapes.
*/
function createIcons() {
for(const p in iconPaths) {
icons[p] = L.icon({
iconUrl: iconPaths[p],
iconSize: [40, 40],
iconAnchor: [20, 20]
})
}
redIcon = L.icon({
iconUrl: "shapes/default_red.svg",
iconSize: [40, 40],
iconAnchor: [20, 20]
})
}
/**
* Creates the leaflet map and all the controls for it
*/
function createMap() {
defaultIcon = L.icon({
iconUrl: "shapes/default.svg",
iconSize: [40, 40],
iconAnchor: [20, 20]
})
createIcons();
map = L.map('map', {
preferCanvas: true,
center: [0, 0],
zoom: 2
});
var toner = new L.StamenTileLayer("toner-lite");
map.addLayer(toner);
var terrain = new L.StamenTileLayer("terrain");
var watercolor = new L.StamenTileLayer("watercolor");
map.options.maxZoom = 13;
// create the layer groups
detailsLayer = L.layerGroup().addTo(map);
linesLayer = L.layerGroup().addTo(map);
// create cluster layer
cluster = L.markerClusterGroup({
chunkedLoading: true,
chunkProgress: (processed, total, time) => {console.log("Progress: " + ((processed/total)*100))},
disableClusteringAtZoom: 13,
zoomToBoundsOnClick: false,
spiderfyOnMaxZoom: false,
})
// on cluster click either zoom or display popups
cluster.on('clusterclick',async function (a) {
if(isLoading)
return;
const markers = a.layer.getAllChildMarkers()
if(markers.length > MAX_RECTS)
{
a.layer.zoomToBounds({padding: [20,20]});
return;
}
let rectsToShow = Array();
markers.forEach(m =>
rects.filter(r => r.marker === m).forEach(f => rectsToShow.push(f))
)
rectsToShow.forEach((r)=> {
const z = map.getZoom();
const proj = map.project(r.latLong(), z);
r.reset(proj);
});
startLoading();
//outsource rearrange algorithm to its own thread
let worker = new Worker("rearrange.js", { type: "module" })
let rectanglesPost = rectsToShow.map(function (r) { return new rectangle(r.x,r.y,r.w,r.h, r.lat, r.long)});
rectanglesPost.forEach( (r,i) => {r.index = rectsToShow[i].index; r._orig_x = rectsToShow[i]._orig_x; r._orig_y = rectsToShow[i]._orig_y})
worker.postMessage(rectanglesPost);
worker.onmessage = async function(e) {
rectsToShow = e.data.map(function (r) { return new rectangle(r.x,r.y,r.w,r.h, r.lat, r.long)});
rectsToShow.forEach( (r,i) =>{ r.index = rectanglesPost[i].index; r._orig_x = rectanglesPost[i]._orig_x; r._orig_y = rectanglesPost[i]._orig_y; })
await createDetails(rectsToShow);
stopLoading();
}
});
map.addLayer(cluster);
// create layers control
let baseLayers = {
"Toner": toner,
"Terrain": terrain,
"Watercolor": watercolor,
}
let overlayLayers = {
"UFOs": cluster,
"Details": detailsLayer,
"Offset Lines" : linesLayer,
}
layerControl = L.control.layers(baseLayers, overlayLayers, {
collapsed: false,
hideSingleBase: true
}).addTo(map);
}
/**
* Returns the parsed coordinates of the entry.
* @param {*} entry
* @returns
*/
function getCoords(entry) {
let lat = entry.city_latitude instanceof Number ? entry.city_latitude : parseFloat(entry.city_latitude)
let long = entry.city_longitude instanceof Number ? entry.city_longitude : parseFloat(entry.city_longitude)
return {lat: lat, long: long}
}
/**
* Creates a marker for the provided entry. Used to initially create the markers
* after loading all the coordinates.
* @param {*} entry
* @returns the created marker.
*/
async function createMarker(entry) {
let icon = icons[entry.shape] ?? defaultIcon;
let marker = L.marker([entry.city_latitude, entry.city_longitude], {icon: icon})
marker.on('click', a => {
if(isLoading)
return;
let rect = rects.filter(r => r.marker === a.sourceTarget);
if(rect != null ) {
startLoading();
resetRectangles(rect);
createDetails(rect);
stopLoading();
}
})
return marker;
}
/**
* resets the rectangles positions according to the current zoom.
* @param {Rectangle[]} rectsToShow
*/
function resetRectangles(rectsToShow)
{
rectsToShow.forEach((r)=> {
const z = map.getZoom();
const proj = map.project(r.latLong(), z);
r.reset(proj);
});
}
/**
* Shows the details of all the data points that are currently visible in the viewport.
* Only show the details if there are less than MAX_RECTS in the viewport.
*/
async function showDetails() {
if(isLoading)
return;
const bounds = map.getBounds();
let rectsToShow = rects.filter(r => bounds.contains(L.latLng(r.lat, r.long)))
if(rectsToShow.length > MAX_RECTS) {
alert("Too many UFOs in view to show the details. Zoom in to get a better view.")
return;
}
console.log("showing details for "+ rectsToShow.length + " items")
startLoading();
resetRectangles(rectsToShow);
let worker = new Worker("rearrange.js", { type: "module" })
let rectanglesPost = rectsToShow.map(function (r) { return new rectangle(r.x,r.y,r.w,r.h, r.lat, r.long)});
rectanglesPost.forEach( (r,i) => {r.index = rectsToShow[i].index; r._orig_x = rectsToShow[i]._orig_x; r._orig_y = rectsToShow[i]._orig_y})
worker.postMessage(rectanglesPost);
worker.onmessage = async function(e) {
rectsToShow = e.data.map(function (r) { return new rectangle(r.x,r.y,r.w,r.h, r.lat, r.long)});
rectsToShow.forEach( (r,i) =>{ r.index = rectanglesPost[i].index; r._orig_x = rectanglesPost[i]._orig_x; r._orig_y = rectanglesPost[i]._orig_y})
console.log(rectsToShow)
await createDetails(rectsToShow);
stopLoading();
}
}
/**
* Used to benchmark the intersections in comparison to brute forcing them.
*/
function intersectionBenchmark() {
const bounds = map.getBounds();
const rectsToShow = rects.filter(r => bounds.contains(L.latLng(r.lat, r.long)))
rectsToShow.forEach((r)=> {
const z = map.getZoom();
const proj = map.project(r.latLong(), z);
r.reset(proj);
});
const startTime = new Date().getTime();
let intersections = bruteForceIntersections(rectsToShow);
const betweentime = new Date().getTime();
let lineits = lineIntersecions(rectsToShow);
const endTime = new Date().getTime();
console.log(intersections.length);
console.log(lineits.length)
console.log(betweentime-startTime);
console.log(endTime-betweentime);
/*
lineits.forEach(li => {
const found = intersections.filter(x => (x.a == li.a && x.b == li.b) || (x.a == li.b && x.b == li.a))
if(found.length == 0) {
console.log("could not find intersection")
console.log(li);
}
})
console.log("intersection check done")
*/
}
/**
* Clears the shown details, fetches the detail data of the rectangles and
* rearranges them to not overlap. Then shows them.
* @param {Rectangle[]} rectsToShow
*/
async function createDetails(rectsToShow) {
// clear detail layers
detailsLayer.clearLayers();
linesLayer.clearLayers();
// fetch detail data
let dataPromises = [];
rectsToShow.forEach(r => {
dataPromises.push(fetchEntry(r.index));
})
// wait on data
let data = await Promise.all(dataPromises);
var bounds = [];
// create popups for details and debug stuff
rectsToShow.forEach((rect, i) => {
const start = map.unproject(rect.original_point());
const end = map.unproject(rect.point());
const p1 = map.unproject(rect.min())
const p2 = map.unproject(rect.max())
const d = data[i];
bounds.push(end)
// sanitize shape
if(d.shape === null || d.shape === '')
{
d.shape = 'unknown';
}
const imgUrl = iconPaths[d.shape] ?? "shapes/default.svg";
const popupContent =
`<div class='ufo-popup'>
<img class='ufo-image' src='`+imgUrl+`'></img>
<b>Shape: </b>`+(d.shape === null ? 'unknown' : d.shape)+ `<br>
<b>State: </b>`+d.state+`<br>
<b>City: </b>`+d.city+`<br>
<b>Duration: </b>`+d.duration+`<br>
<b>On: </b>`+d.date_time+`<br>
<a href="`+d.report_link+`">Link to report</a><br>
</div>`;
// create line from origin to new position
L.polyline([start, end], {color: colors[iconColors[d.shape]]}).addTo(linesLayer);
// create the detail popup for the rectangel
let popup = L.ufopopup({
minWidth: rect.w - 30, // 20 is CSS padding, compensate a bit more
maxWidth: rect.w - 30,
maxHeight: rect.h - 30,
offset: L.point(0,rect.h/2 + 15), // 20 is the offset of the bottom tip
autoPan: false,
closeButton: true,
autoClose: false,
closeOnEscape: false,
closeOnClick: false,
}).setLatLng(L.latLng(end))
.setContent(popupContent).addTo(detailsLayer);
})
var detailsBounds = new L.LatLngBounds(bounds);
map.options.maxZoom = map.getZoom();
map.fitBounds(detailsBounds,{padding: [30,30]} );
map.options.maxZoom = 13;
}
/**
* closes all detail pannels
* @returns {Promise<void>}
*/
async function closeAll(){
detailsLayer.clearLayers();
linesLayer.clearLayers();
}
/**
* Fetches the detail data for a certain entry id.
* @param {Number} id - The entries id
* @returns the parsed data
*/
async function fetchEntry(id) {
let data = await fetch('./data/json/json_'+String(id).padStart(6, '0'))
let parsed = await data.json()
return parsed
}
/**
* Loads and parses the given coordinates csv file.
* Also creates the corresponding rectangles and markers.
* Used in the initial loading step.
* @param {String} csvFile
*/
async function loadData(csvFile) {
//let data = await fetch('./data/data.csv')
let data = await fetch(csvFile)
let dataText = await data.text()
let csvData = CSV.parse(dataText, csvDialect)
// parse csv data and filter invalid entries
let parsedData = csvData.map((x,index) => {
try {
let data = x
let entry = {id: dataCount++}
for (let i = 0; i < headings.length && i < data.length; i++) {
entry[headings[i]] = data[i]
}
let coords = getCoords(entry);
entry.city_latitude = coords.lat + (Math.random()-0.5)/50;
entry.city_longitude = coords.long+ (Math.random()-0.5)/50;
return entry;
} catch (error) {
console.log('parsing failed for line: ' + index)
return null
}
}).filter((x,i,a) => {
return !(isNaN(x.city_latitude) || isNaN(x.city_longitude)) && x != null;
})
// create the markers
let markers = await Promise.all(parsedData.map((x, i) => {
return createMarker(parsedData[i])
}))
// create the rectangles
parsedData.forEach((x,i) => {
let r = new rectangle(0, 0, 200, 200, x.city_latitude, x.city_longitude);
r.dataIndex = x.id;
r.index = x.index;
r.marker = markers[i];
rects.push(r);
});
cluster.addLayers(markers);
}
document.addEventListener("DOMContentLoaded", async function () {
await null; // apprently needed so browser does it async
document.querySelector('#details-button').addEventListener('click', showDetails);
document.querySelector('#close-all-button').addEventListener('click', closeAll);
loading = document.getElementById("loading");
// first create the map
createMap()
// load all the coordinates and create their markers and rectangles
startLoading();
const chunkCount = 137;
var promises = [];
for(let i = 0; i < chunkCount; i++) {
promises.push(loadData('./data/coords_'+String(i).padStart(3,'0')));
}
Promise.all(promises).then(x => stopLoading());
})