saasbook/baytrailbirding

View on GitHub
app/assets/javascripts/index.js

Summary

Maintainability
D
2 days
Test Coverage
File `index.js` has 321 lines of code (exceeds 250 allowed). Consider refactoring.
// ESLINT:
/* global L, moment, $ */
 
 
const MAX_RADIUS = 50; // Radius distance in KM
 
/**
* Unit conversions
*/
const Unit = {
mi2km: mi => mi * 1.609,
mi2m: mi => Unit.mi2km(mi) * 1000,
km2mi: km => km / 1.609,
m2mi: m => Unit.km2mi(m / 1000)
};
 
/**
* AJAX Wrapper Class
*/
class API {
/**
* Sends a GET request to url with data
* @param {string} url
* @param {Object} data
* @returns {Promise<Object>} response data
*/
static get(url, data) {
const request = {
method: 'GET',
url,
data
};
 
return new Promise(resolve => {
$.ajax(request).done(data => resolve(data));
});
}
}
 
/**
* Wrapper class to help distinguish locations
*/
class Location {
/**
* Formatted location data
* @param {Object} location - Raw location data
*/
constructor(location) {
Object.assign(this, JSON.parse(JSON.stringify(location)));
 
// TODO: See comments above
}
 
/**
* Converts raw location data into a formatted one
* @param {Object} location - Raw location data
* @returns {Location} Formatted location data
*/
static from(location) {
return new Location(location);
}
}
 
/**
* Wrapper class to help distinguish hotspots
* @property {Object} loc - Location object
* @property {string} loc.id - Location ID
* @property {string} loc.name - Location Name
* @property {number} loc.lat - Location latitude
* @property {number} loc.lng - Location longitude
* @property {L.LatLng} loc.latLng - Location Leaflet position
* @property {Object} code - Code object
* @property {string} code.country - Country code
* @property {string} code.subnat1 - Sub National Level 1 code
* @property {string} code.subnat2 - Sub National Level 2 code
* @property {Object} obs - Observation object
* @property {Date} obs.date - Observation date
* @property {number} cnt - Number of birds at this hotspot
*/
Similar blocks of code found in 2 locations. Consider refactoring.
class Hotspot {
/**
* Formatted hotspot data
* @param {Object} hotspot - Raw hotspot data
*/
constructor(hotspot) {
Object.assign(this, JSON.parse(JSON.stringify(hotspot)));
this.obs.date = new Date(this.obs.date);
this.loc.latLng = L.latLng(this.loc.lat, this.loc.lng);
}
 
/**
* Converts raw hotspot data into a formatted one
* @param {Object} hotspot - Raw hotspot data
* @returns {Hotspot} Formatted hotspot data
*/
static from(hotspot) {
return new Hotspot(hotspot);
}
}
 
/**
* Wrapper class to help distinguish birds
* @property {string} speciesCode - Code of the species; can contain multiple subspecies
* @property {string} subId - ID of the bird observation; use this as the ID
* @property {Object} name - Name object of the bird
* @property {string} name.sci - Scientific name
* @property {string} name.com - Common name
* @property {Object} loc - Location object
* @property {string} loc.id - Location ID
* @property {string} loc.name - Location Name
* @property {number} loc.lat - Location latitude
* @property {number} loc.lng - Location longitude
* @property {L.LatLng} loc.latLng - Location Leaflet position
* @property {boolean} loc.private - Whether or not the location is private
* @property {number} loc.dist - Location distance from queried lat, long
* @property {Object} obs - Observation object
* @property {Date} obs.date - Observation date
* @property {number} obs.cnt - Observation count
* @property {boolean} obs.valid - Whether or not the observation is valid
* @property {boolean} obs.reviewed - Whether or not the observation has been reviewed
* @property {null} img - NOT CURRENTLY USED
*/
Similar blocks of code found in 2 locations. Consider refactoring.
class Bird {
/**
* Formatted bird data
* @param {Object} bird - Raw bird data
*/
constructor(bird) {
Object.assign(this, JSON.parse(JSON.stringify(bird)));
this.obs.date = new Date(this.obs.date);
this.loc.latLng = L.latLng(this.loc.lat, this.loc.lng);
}
 
/**
* Converts raw bird data into a formatted one
* @param {Object} bird - Raw bird data
* @returns {Bird} Formatted bird data
*/
static from(bird) {
return new Bird(bird);
}
}
 
/**
* Class to access our API
*/
class Ebird {
/**
* Returns a list of birds from the ebird API through our API
* @example
* // Gets a list of birds WITHOUT hotspot
* await getBirds(37.8039, -122.2591, 15);
* @example
* // Gets a list of birds WITH hotspot
* await getBirds(0, 0, 0, 'L6530472');
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @param {number} radius - Radius/Distance from lat, lng in KM
* @param {hotspot} [hotspot] - OPTIONAL, ID of the hotspot
* @returns {Promise<Bird[]>} A promise to a list of formatted birds
*/
static async getBirds(lat, lng, radius, hotspot) {
const url = '/ebird/birds';
const data = {
lat,
lng,
radius,
hotspot
};
 
const birds = await API.get(url, data);
 
return birds.map(Bird.from);
}
 
/**
* Returns a list of hotspots from the ebird API through our API
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @param {number} radius - Radius/Distance from lat, lng in KM
* @returns {Promise<Hotspot[]>} A promise to a list of formatted hotspots
*/
static async getHotspots(lat, lng, radius) {
const url = '/ebird/hotspots';
const data = {
lat,
lng,
radius
};
 
const hotspots = await API.get(url, data);
 
return hotspots.map(Hotspot.from);
}
 
/**
* Returns a list of locations from our API. Even though this is labeled in ebird,
* we keep it together to prevent creating another class
* @returns {Promise<Location[]>} A promise to a list of formatted locations
*/
static async getLocations() {
// TODO: See method comment above
}
}
 
/**
* The general map
* @property {L.Map} map - Leaflet map instance
* @property {L.Marker} position - Current user position marker
* @property {L.Marker} selected - Currently selected marker
* @property {L.MarkerClusterGroup} birds - Marker cluster representing a list of bird markers
* @property {L.MarkerClusterGroup} hotspots - Marker cluster representing a list of hotspot markers
* @property {L.MarkerClusterGroup} locations - Marker cluster representing a list of location markers
* @property {L.LayerGroup} markers - Group of all markers (includes the birds, hotspots, and locations)
*/
class BirdMap {
/**
* Creates the map
* @param {Element} elementId - Element for the map
* @param {L.LatLng} currentPosition - Leaflet LatLng object that contains the current position
*/
Function `constructor` has 31 lines of code (exceeds 25 allowed). Consider refactoring.
constructor(elementId, currentPosition) {
const tileLayerSource = 'https://a.tiles.mapbox.com/styles/v1/lohneswright/ciocejooj006obdnjhmd2x9qp/tiles/{z}/{x}/{y}?access_token={accessToken}';
 
const map = L.map(elementId).setView([37.42, -121.91], 13);
 
L.tileLayer(tileLayerSource, {
maxZoom: 22,
accessToken: 'pk.eyJ1IjoibG9obmVzd3JpZ2h0IiwiYSI6IngzbVlqNnMifQ.OwxjrBKoGXc60NB5x6GKzw',
attribution: '<a href="http://jawg.io" title="Tiles Courtesy of Jawg Maps" target="_blank">&copy; <b>Jawg</b>Maps</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
 
this.map = map;
 
this.setUserPosition(currentPosition);
 
this.map.panTo(currentPosition);
 
const km = MAX_RADIUS * 1000;
 
const m = Math.floor(km * Math.sqrt(2));
 
const bounds = currentPosition.toBounds(m);
 
this.map.once('moveend', () => {
this.map.setMinZoom(this.map.getZoom());
 
this.birds = L.markerClusterGroup();
this.hotspots = L.markerClusterGroup();
this.locations = L.markerClusterGroup();
 
this.markers = L.layerGroup([this.birds, this.hotspots, this.locations]);
 
this.markers.on('clusterclick', () => {
if (this.selected) this.selected.setIcon(this.selected.defaultIcon);
$('.overlay').attr('hidden', '');
});
 
this.selected = null;
 
this.map.on('moveend', () => {
this.load();
});
 
this.map.addLayer(this.markers);
 
this.load();
});
 
this.map.fitBounds(bounds);
}
 
/**
* Generates a list of bird markers using the current position of the user.
* Keeps only birds that fit within the current boundaries.
* Keeps only birds that aren't near hotspots.
* Keeps only new birds that haven't been added yet.
* @returns {Promise<L.Marker[]>} A promise to a list of bird markers
*/
async getBirds() {
const { lat, lng } = this.getCurrPosition();
const birds = await Ebird.getBirds(lat, lng, this.getRadius());
const bounds = this.map.getBounds();
Similar blocks of code found in 2 locations. Consider refactoring.
const markers = birds
.filter(bird => bounds.contains(bird.loc.latLng))
.map(bird => this.createBird(bird))
.filter(birdMarker => !this.hotspots.getLayers().find(hotspotMarker => hotspotMarker.getLatLng().equals(birdMarker.getLatLng())));
 
return markers;
}
 
/**
* Generates a list of hotspot markers using the current position of the user
* Keeps only hotspots that fit within the current boundaries.
* Keeps only new hotspots that haven't been added yet.
* @returns {Promise<L.Marker[]>} A promise to a list of hotspot markers
*/
async getHotspots() {
const { lat, lng } = this.getCurrPosition();
const hotspots = await Ebird.getHotspots(lat, lng, this.getRadius());
const bounds = this.map.getBounds();
Similar blocks of code found in 2 locations. Consider refactoring.
const markers = hotspots
.filter(hotspot => bounds.contains(hotspot.loc.latLng))
.map(hotspot => this.createHotspot(hotspot));
 
return markers;
}
 
/**
* Generates a list of location markers using the current position of the user
* Keeps only new locations that haven't been added yet.
* @returns {Promise<L.Marker[]>} A promise to a list of location markers
*/
async getLocations() {
// TODO: See method comment above
}
 
/**
* Creates a marker, but DOES NOT add it to the map
* @param {L.LatLng} latLng - Location of the marker
* @param {string} customId - Marker identifier to prevent duplicate markers
* @param {L.Icon} icon - Icon shown on map when the marker is NOT selected
* @param {L.Icon} selectedIcon - Icon shown on map when the marker IS selected
* @returns {L.Marker} Newly generated marker
*/
createMarker(latLng, customId, icon, selectedIcon) {
/**
* @property {L.Icon} defaultIcon - Icon shown on map when the marker is NOT selected
* @property {L.Icon} selectedIcon - Icon shown on map when the marker IS selected
* @property {string} customId - Marker identifier to prevent duplicate markers
*/
const marker = L.marker(latLng, {
icon,
riseOnHover: true
});
 
marker.defaultIcon = icon;
marker.selectedIcon = selectedIcon;
marker.customId = customId;
 
/**
* On click:
* deselect the old marker
* select the current marker
* pan to the current marker
*/
marker.on('click', () => {
const position = marker.getLatLng();
if (this.selected) this.selected.setIcon(this.selected.defaultIcon);
this.selected = marker;
this.selected.setIcon(selectedIcon);
this.setCurrPosition(position);
});
 
return marker;
}
 
/**
* Creates a bird marker
* @param {Bird} bird - Formatted bird data
* @returns {L.Marker} Marker representing a bird
*/
Function `createBird` has 36 lines of code (exceeds 25 allowed). Consider refactoring.
createBird(bird) {
const { latLng } = bird.loc;
 
const icon = L.divIcon({
html: '<i class="fa-solid fa-dove fa-2x"></i>',
iconSize: [20, 20],
className: 'bird-icon'
});
 
const selectedIcon = L.divIcon({
html: '<i class="fa-solid fa-dove fa-2x"></i>',
iconSize: [20, 20],
className: 'bird-icon-selected'
});
 
const marker = this.createMarker(latLng, bird.subId, icon, selectedIcon);
 
/**
* On click:
* displays the bird
*/
marker.on('click', () => {
$('.overlay')
.html(`
<div class="expand-icon"></div>
<div class="content">
<div class="bird-name">${bird.name.com}</div>
<div class="science-name">${bird.name.sci}</div>
<div class="bird-img-wrapper">
<img class="bird-img" src="/duck.png">
</div>
<div class="location-wrapper">
<div class="location">Loc</div>
<div class="separator"><i class="fa-solid fa-circle"></i></div>
<div class="distance">${Unit.m2mi(marker.getLatLng().distanceTo(this.getUserPosition())).toFixed(2)} mi</div>
</div>
<div class="description-wrapper">
<div class="description-header">Description:</div>
<div class="description-content">No Description Available</div>
</div>
</div>
`)
.removeAttr('hidden');
});
 
return marker;
}
 
/**
* Creates a hotspot marker
* @param {Hotspot} hotspot - Formatted hotspot data
* @returns {L.Marker} Marker representing a hotspot
*/
createHotspot(hotspot) {
const { latLng } = hotspot.loc;
 
const icon = L.divIcon({
html: '<i class="fa-solid fa-binoculars fa-2x"></i>',
iconSize: [20, 20],
className: 'hotspot-icon'
});
 
const selectedIcon = L.divIcon({
html: '<i class="fa-solid fa-binoculars fa-2x"></i>',
iconSize: [20, 20],
className: 'hotspot-icon-selected'
});
 
const marker = this.createMarker(latLng, hotspot.loc.id, icon, selectedIcon);
 
/**
* On click:
* displays the hotspot
* gets a list of birds at the hotspot
* displays the birds on a grid with the hotspot data
*/
marker.on('click', () => {
// TODO: See implementation comment above
});
 
return marker;
}
 
/**
* Creates a location marker
* @param {Location} hotsplocationot - Formatted location data
* @returns {L.Marker} Marker representing a location
*/
createLocation(location) {
// TODO: See method comment above
}
 
/**
* Loads the markers for the hotspots, then the birds
*/
async load() {
const hotspots = await this.getHotspots();
this.locations.insert(...hotspots);
 
const birds = await this.getBirds();
this.birds.insert(...birds);
}
 
/**
* Offset pans to the marker with the target position
* @param {L.LatLng} latLng - The location of the marker to offset pan to
*/
setCurrPosition(latLng) {
this.map.panToOffset(latLng, [window.innerWidth / 4, 0]);
}
 
/**
* Gets the center of the map, setCurrPosition and getCurrPosition are NOT the same
* @returns {L.LatLng} The location of the center of the map
*/
getCurrPosition() {
return this.map.getCenter();
}
 
/**
* Sets the position of the user marker. If no marker exists, create it
* @param {L.LatLng} latLng - Position of the user
*/
setUserPosition(latLng) {
if (!this.position) {
const icon = L.divIcon({
html: '<i class="fa-solid fa-street-view fa-3x"></i>',
iconSize: [20, 20],
className: 'user-icon'
});
 
const marker = L.marker(latLng, {
icon,
riseOnHover: true
});
this.position = marker;
 
this.map.addLayer(this.position);
}
this.position.setLatLng(latLng);
}
 
/**
* Get the current user position
* @returns {L.LatLng} Position of the user
*/
getUserPosition() {
return this.position.getLatLng();
}
 
/**
* Calculates the radius given the current map boundaries. This is critical for loading data.
* @returns {number} Radius in KM
*/
getRadius() {
const bounds = this.map.getBounds();
const ne = bounds.getNorthEast();
const nw = bounds.getNorthWest();
const m = nw.distanceTo(ne);
const km = m / 1000;
const r = Math.min(Math.ceil(km / Math.sqrt(2)), MAX_RADIUS);
 
return r;
}
}
 
/**
* Wrapper of geolocation API
*/
class GPS {
/**
* Creates a new GPS. Prevents us from having too many watchers.
*/
constructor() {
this.watchId = null;
}
 
/**
* Fetches the current user location
* @param {number} defaultLat - Default latitude
* @param {number} defaultLng - Default longitude
* @returns {Promise<L.LatLng>} A promise to the user's current location
*/
getCurrentPosition(defaultLat, defaultLng) {
const defaultPosition = L.latLng(defaultLat, defaultLng);
 
const options = {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
};
 
return new Promise(resolve => {
Similar blocks of code found in 2 locations. Consider refactoring.
navigator.geolocation.getCurrentPosition(
position => {
const {
coords
} = position;
const {
latitude, longitude
} = coords;
 
resolve(L.latLng(latitude, longitude));
},
() => resolve(defaultPosition),
options
);
});
}
 
/**
* Watches the current user's position and calls `onPosChange` when the user's position changes
* @callback onPosChange
* @param {L.LatLng} latLng - Contains the position of the user
* @returns {number} Watch ID
*/
watchCurrentPosition(onPosChange) {
const defaultPosition = L.latLng(defaultLat, defaultLng);
 
const options = {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
};
 
Similar blocks of code found in 2 locations. Consider refactoring.
const watchId = navigator.geolocation.watchPosition(
position => {
const {
coords
} = position;
const {
latitude, longitude
} = coords;
 
onPosChange(L.latLng(latitude, longitude));
},
() => onPosChange(defaultPosition),
options
);
this.watchId = watchId;
 
return watchId;
}
 
/**
* Clear the current watch
*/
clearWatchCurrentPosition() {
if (!this.watchId) throw new Error('Not currently watching position');
 
navigator.geolocation.clearWatch(this.watchId);
 
this.watchId = null;
}
}
 
// Keep these variables outside, so you can use the console inspection tool to debug
let mymap = null;
let gps = null;
 
window.addEventListener('load', async () => {
/**
* Pan to location on map with an offset
* @param {L.LatLng} latlng - Position to pan to
* @param {[xOffset: number, yOffset: number]} offset - Pairing of x and y offsets in PX values
* @param {Object} [options] - OPTIONAL, additional pan options
* @returns {L.Map} Map
*/
L.Map.prototype.panToOffset = function(latlng, offset, options) {
const x = this.latLngToContainerPoint(latlng).x - offset[0];
const y = this.latLngToContainerPoint(latlng).y - offset[1];
const point = this.containerPointToLatLng([x, y]);
return this.setView(point, this.getZoom(), {
pan: options
});
};
 
/**
* Inserts layer(s) to a map. Excludes duplicate markers.
* @param {...L.Marker} layers - Each argument is processed as a different layer
* @returns {L.LayerGroup[]} List of the same layer group. This is really not needed, but is good to have
*/
L.LayerGroup.prototype.insert = function(...layers) {
return layers.map(layer => {
if (this.getLayers().find(currLayer => currLayer.customId === layer.customId)) return null;
return this.addLayer(layer);
});
};
 
// Initialize the GPS
gps = new GPS();
 
// Load the map
mymap = new BirdMap('leaflet-map', await gps.getCurrentPosition(37.8039, -122.2591));
 
$('#navigation').click(() => {
document.querySelector('nav').toggleAttribute('hidden');
});
});