diff --git a/complete-app/credentials.example.js b/complete-app/credentials.example.js new file mode 100644 index 0000000..9e0277f --- /dev/null +++ b/complete-app/credentials.example.js @@ -0,0 +1,11 @@ +/* Fill in your credentials here and rename the file + to credentials.js + + Tip: ssh -L 5439:127.0.0.1:5432 maps.metastudio.org +*/ +module.exports.pg = { + user: 'you', + host: 'localhost', + database: 'your_database', + port: '5432' +} diff --git a/complete-app/package.json b/complete-app/package.json new file mode 100644 index 0000000..9157e84 --- /dev/null +++ b/complete-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "complete-app", + "version": "1.0.0", + "description": "", + "main": "server.js", + "dependencies": { + "express": "^4.13.3", + "pg": "^4.4.3", + "socket.io": "^1.3.7" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "author": "John J Czaplewski and Puneet Kishor ", + "license": "CC0 Public Domain Dedication" +} diff --git a/complete-app/public/css/styles.css b/complete-app/public/css/styles.css new file mode 100644 index 0000000..dc95730 --- /dev/null +++ b/complete-app/public/css/styles.css @@ -0,0 +1,71 @@ +body { + padding: 0; + margin: 0; +} +html, body, #map { + height: 100%; +} +.on { + top: 0; +} +.off { + top: -1000px; +} +#form { + padding: 10px; + background-color: rgba(255,255,255,0.7); + position: absolute; + height: 50%; + z-index: 9999; +} +.cache-map { + position: absolute; + bottom: 0; + left: 0; + padding: 10px; + height: 40px; + width: 80px; + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + cursor: pointer; + border-radius: 4px; + z-index: 999; +} +.cache-map-button { + font-weight: bold; +} +.cache-loading { + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + padding: 50px; + position: absolute; + height: 50%; + width: 50%; + left: 15%; + top: 15%; + border-radius: 6px; + font-size: 25px; + margin: 0 auto; + z-index: 9999; + display: none; +} +#cache-map { + position: absolute; + top: 80px; + left: 10px; + width: 30px; + height: 30px; + text-decoration: none; + background-color: white; + z-index: 1000; + border-radius: 4px 4px 4px 4px; + -moz-border-radius: 4px 4px 4px 4px; + -webkit-border-radius: 4px 4px 4px 4px; + border: 1px solid #000000; + text-align: center; + font-size: x-large; +} \ No newline at end of file diff --git a/complete-app/public/img/favicon.ico b/complete-app/public/img/favicon.ico new file mode 100644 index 0000000..7cb6056 Binary files /dev/null and b/complete-app/public/img/favicon.ico differ diff --git a/complete-app/public/index.html b/complete-app/public/index.html new file mode 100644 index 0000000..74d4d73 --- /dev/null +++ b/complete-app/public/index.html @@ -0,0 +1,93 @@ + + + + Mapping for Citizen Science + + + + + + + + + + + + + + + + +
+ + 📲 + +
+ + + + + + + + + + + + +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/complete-app/public/js/L.TileLayer.PouchDBCached.js b/complete-app/public/js/L.TileLayer.PouchDBCached.js new file mode 100644 index 0000000..4d9c3d2 --- /dev/null +++ b/complete-app/public/js/L.TileLayer.PouchDBCached.js @@ -0,0 +1,219 @@ + + +L.TileLayer.addInitHook(function() { + + if (!this.options.useCache) { + this._db = null; + this._canvas = null; + return; + } + + this._db = new PouchDB('offline-tiles'); + this._canvas = document.createElement('canvas'); + + if (!(this._canvas.getContext && this._canvas.getContext('2d'))) { + // HTML5 canvas is needed to pack the tiles as base64 data. If + // the browser doesn't support canvas, the code will forcefully + // skip caching the tiles. + this._canvas = null; + } +}); + +L.TileLayer.prototype.options.useCache = false; +L.TileLayer.prototype.options.saveToCache = true; +L.TileLayer.prototype.options.useOnlyCache = false; +L.TileLayer.prototype.options.cacheMaxAge = 24*3600*1000; + + +L.TileLayer.include({ + + // Overwrites L.TileLayer.prototype.createTile + createTile: function(coords, done) { + var tile = document.createElement('img'); + + tile.onerror = L.bind(this._tileOnError, this, done, tile); + + if (this.options.crossOrigin) { + tile.crossOrigin = ''; + } + + /* + Alt tag is *set to empty string to keep screen readers from reading URL and for compliance reasons + http://www.w3.org/TR/WCAG20-TECHS/H67 + */ + tile.alt = ''; + + var tileUrl = this.getTileUrl(coords); + + if (this.options.useCache && this._canvas) { + this._db.get(tileUrl, {revs_info: true}, this._onCacheLookup(tile, tileUrl, done)); + } else { + // Fall back to standard behaviour + tile.onload = L.bind(this._tileOnLoad, this, done, tile); + } + + tile.src = tileUrl; + return tile; + }, + + // Returns a callback (closure over tile/key/originalSrc) to be run when the DB + // backend is finished with a fetch operation. + _onCacheLookup: function(tile, tileUrl, done) { + return function(err,data) { + if (data) { + this.fire('tilecachehit', { + tile: tile, + url: tileUrl + }); + if (Date.now() > data.timestamp + this.options.cacheMaxAge && !this.options.useOnlyCache) { + // Tile is too old, try to refresh it + console.log('Tile is too old: ', tileUrl); + + if (this.options.saveToCache) { + tile.onload = L.bind(this._saveTile, this, tile, tileUrl, data._revs_info[0].rev, done); + } + tile.crossOrigin = 'Anonymous'; + tile.src = tileUrl; + tile.onerror = function(ev) { + // If the tile is too old but couldn't be fetched from the network, + // serve the one still in cache. + this.src = data.dataUrl; + } + } else { + // Serve tile from cached data + console.log('Tile is cached: ', tileUrl); + tile.onload = L.bind(this._tileOnLoad, this, done, tile); + tile.src = data.dataUrl; // data.dataUrl is already a base64-encoded PNG image. + } + } else { + this.fire('tilecachemiss', { + tile: tile, + url: tileUrl + }); + if (this.options.useOnlyCache) { + // Offline, not cached +// console.log('Tile not in cache', tileUrl); + tile.onload = this._tileOnLoad; + tile.src = L.Util.emptyImageUrl; + } else { + // Online, not cached, request the tile normally +// console.log('Requesting tile normally', tileUrl); + if (this.options.saveToCache) { + tile.onload = L.bind(this._saveTile, this, tile, tileUrl, null, done); + } else { + tile.onload = L.bind(this._tileOnLoad, this, done, tile); + } + tile.crossOrigin = 'Anonymous'; + tile.src = tileUrl; + } + } + }.bind(this); + }, + + // Returns an event handler (closure over DB key), which runs + // when the tile (which is an ) is ready. + // The handler will delete the document from pouchDB if an existing revision is passed. + // This will keep just the latest valid copy of the image in the cache. + _saveTile: function(tile, tileUrl, existingRevision, done) { + if (this._canvas === null) return; + this._canvas.width = tile.naturalWidth || tile.width; + this._canvas.height = tile.naturalHeight || tile.height; + + var context = this._canvas.getContext('2d'); + context.drawImage(tile, 0, 0); + + var dataUrl = this._canvas.toDataURL('image/png'); + var doc = {dataUrl: dataUrl, timestamp: Date.now()}; + + if (existingRevision) { + this._db.remove(tileUrl, existingRevision); + } + console.log(doc, tileUrl) + this._db.put(doc, tileUrl, doc.timestamp); + + if (done) { done(); } + }, + + + // Seeds the cache given a bounding box (latLngBounds), and + // the minimum and maximum zoom levels + // Use with care! This can spawn thousands of requests and + // flood tileservers! + seed: function(bbox, minZoom, maxZoom) { + if (minZoom > maxZoom) return; + if (!this._map) return; + console.log(bbox, minZoom, maxZoom) + var queue = []; + + var polygon = { + type: "Polygon", + coordinates: [ + [ + [bbox._southWest.lng, bbox._southWest.lat], + [bbox._southWest.lng, bbox._northEast.lat], + [bbox._northEast.lng, bbox._northEast.lat], + [bbox._northEast.lng, bbox._southWest.lat], + [bbox._southWest.lng, bbox._southWest.lat] + ] + ] + } + var tiles = []; + + for (var i = minZoom; i < maxZoom + 1; i++) { + tiles = tiles.concat(tileCover(polygon, {min_zoom: i, max_zoom: i})); + } + + this._seedTiles(tiles); + + }, + + _seedTiles(queue) { + document.querySelector(".cache-loading").style.display = 'flex'; + var baseURL = this._url; + var accountedFor = 0; + + async.each(queue, function(item, callback) { + var url = baseURL.replace('{x}', item[0]).replace('{y}', item[1]).replace('{z}', item[2]); + + this._db.get(url, function(error, data) { + if (!data) { + URI2Base64(url, function(data) { + var doc = {dataUrl: data, timestamp: Date.now()}; + + this._db.put(doc, url, doc.timestamp); + accountedFor += 1; + + console.log(accountedFor + ' of ' + queue.length); + callback(null); + }.bind(this)); + } else { + callback(null); + } + }.bind(this)); + }.bind(this), function(done) { + console.log("Done!") + document.querySelector(".cache-loading").style.display = 'none'; + }); + + } + +}); + +function URI2Base64(uri, callback) { + var img = new Image(); + img.crossOrigin = 'Anonymous'; + img.onload = function() { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + var dataURL; + canvas.height = this.height; + canvas.width = this.width; + ctx.drawImage(this, 0, 0); + dataURL = canvas.toDataURL('image/png', 1.0); + + callback(dataURL); + + canvas = null; + } + img.src = uri; +} diff --git a/complete-app/public/js/script.js b/complete-app/public/js/script.js new file mode 100644 index 0000000..ac1e843 --- /dev/null +++ b/complete-app/public/js/script.js @@ -0,0 +1,270 @@ +var CSE = {}; + +//A poor person's jQuery-type $ selector +CSE.$ = function(el) { + if (el.substr(0, 1) === "#") { + return document.getElementById(el.substr(1)); + } + else if (el.substr(0, 1) === ".") { + return document.getElementsByClassName(el.substr(1)); + } + else { + return document.getElementsByTagName(el); + } +}; + +// Function to create a unique UUID for each point +CSE.getID = function() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = crypto.getRandomValues(new Uint8Array(1))[0]%16|0, v = c == 'x' ? + r : (r&0x3|0x8); + return v.toString(16); + }); +}; + +// Initialize the local database +CSE.db = new PouchDB('parleglite'); + +CSE.socket = io(); + +// Create a variable to keep track of our things. +// Each key of this object is a thing UUID +CSE.things = {}; + +CSE.tiles = null; + +// Reserve a variable for the map… +CSE.map = null; + +// and for storing the center of the map when a point is being added +CSE.currentLatLng = {lat: 0, lng: 0}; + +CSE.locations = []; + +CSE.markers = { + + prev_loc : { + weight: 2, + fill: true, + radius: 5, + opacity: 0.5, + fillOpacity: 0.5, + color: "#0000ff" + }, + + curr_loc : { + weight: 2, + fill: true, + radius: 5, + opacity: 1, + fillOpacity: 1, + color: "#0000ff" + }, + + synced : { + weight: 2, + fill: true, + radius: 5, + opacity: 1, + fillOpacity: 1, + color: "#00ff00" + }, + + unsynced : { + weight: 2, + fill: true, + radius: 5, + opacity: 1, + fillOpacity: 1, + color: "#ff0000" + } +}; + +// Handle the form submission +CSE.submitForm = function(event) { + event.preventDefault(); + CSE.$("#form").className = "off"; + + // Save data to the in-browser database + CSE.db.put({ + _id: CSE.$("#id").value, + lat: CSE.$("#lat").value, + lng: CSE.$("#lng").value, + name: CSE.$("#name").value, + desc: CSE.$("#desc").value, + synced: false + }); + + // Pan back to the original location + CSE.map.panTo([CSE.currentLatLng.lat, CSE.currentLatLng.lng]); +}; + +// Undo the marker +CSE.removeMarker = function(marker) { + event.preventDefault(); + CSE.$("#form").className = "off"; + + // Remove the marker from the map + CSE.map.removeLayer(marker); + + /* + // Remove the point from the database + db.get(id).then(function(doc) { + return db.remove(doc); + }).then(function (result) { + + // handle result + + }).catch(function (err) { + console.log(err); + }); + */ + + // Pan back to the original location + CSE.map.panTo([CSE.currentLatLng.lat, CSE.currentLatLng.lng]); + + // // Remove the point from the database + // CSE.db.get(id).then(function(doc) { + // return CSE.db.remove(doc); + // }).then(function (result) { + // // handle result + // }).catch(function (err) { + // console.log(err); + // }); +}; + +// Create the map on load +CSE.makeMap = function() { + + // Initialize a new leaflet map + CSE.map = L.map('map'); + + // Add our tiles to new map + CSE.tiles = L.tileLayer('//14.139.123.7/tiles/basemap/{z}/{x}/{y}/tile.png', { + useCache: true, + saveToCache: false, + maxZoom: 18, + attribution: 'Map data © OpenStreetMap contributors, ' + + 'CC-BY-SA, ' + 'Style by Mapbox' + }).addTo(CSE.map); + + //navigator.geolocation.watchPosition(success_callback_function, error_callback_function, position_options) + + // Handle user location found + CSE.map.on("locationfound", function(event) { + + //CSE.map.removeLayer(CSE.curr_loc); + + // Add a blue marker for current location + var loc = L.circleMarker(loc, CSE.markers.prev_loc); + + // Insert current location into the locations array + CSE.locations.push(event.latlng); + + // Create a trail of points + // CSE.locations.forEach(function(loc) { + // .addTo(CSE.map); + // }); + + // // Calculate radius of circle given location accuracy + // var radius = event.accuracy / 2; + // + // // Add a circle halo for the marker to represent accuracy error + // L.circle(event.latlng, radius).addTo(CSE.map); + // + // CSE.curr_loc.bindPopup( + // "You are within " + radius + " meters from this point" + // ); + // + // // Close the popup after 3 seconds + // setTimeout(function() { + // curr_loc.closePopup(); + // }, 3000) + }); + + // Alert the user to a location error + CSE.map.on("locationerror", function(event) { + alert(event.message); + }); + + // Fire an event to find the user's location and draw the map + CSE.map.locate({ + watch: true, + setView: true, + //enableHighAccuracy: true, + maximumAge: 30000 + }); + + // Handle the user right clicking or long pressing on the map + CSE.map.on("contextmenu", function(event) { + var lat = event.latlng.lat.toFixed(6), + lng = event.latlng.lng.toFixed(6), + id = CSE.getID(); + + CSE.$("#id").value = id; + CSE.$("#lat").value = lat; + CSE.$("#lng").value = lng; + + // Add a red (unsynced) marker to the map + var thing = L.circleMarker(event.latlng, CSE.markers.unsynced) + .addTo(CSE.map); + + // // Toggle the form on + CSE.$("#form").className = "on"; + + // Store the center of the map + var center = CSE.map.getCenter(); + CSE.currentLatLng.lat = center.lat; + CSE.currentLatLng.lng = center.lng; + + // Pan the map to make sure the added marker is visible + CSE.map.panToOffset([lat, lng], [0, 150]); + + // Add an event listener to undo the marker + CSE.$("#undo") + .addEventListener("click", function(event) { + event.preventDefault(); + + // Make sure all form fields are empty + CSE.$("#id").value = ""; + CSE.$("#lat").value = ""; + CSE.$("#lng").value = ""; + CSE.$("#name").value = ""; + CSE.$("#desc").value = ""; + + CSE.$("#form").className = "off"; + + // Remove the marker from the map + CSE.map.removeLayer(thing); + + // Pan back to the original location + CSE.map.panTo([center.lat, center.lng]); + }); + }); + + // When we load the page, check if we have any existing points + CSE.db.allDocs().then(function(result) { + + // For each row… + result.rows.forEach(function(row) { + + // Retrieve the information from the local database… + CSE.db.get(row.id).then(function(point) { + + // and add a marker to map + CSE.things[point._id] = L.circleMarker( + [point.lat, point.lng], + point.synced ? CSE.markers.synced : CSE.markers.unsynced + ) + .addTo(CSE.map); + + CSE.things[point._id] + .bindPopup( + 'Name: ' + point.name + '
' + + 'Description: ' + point.desc + '
' + + (point.synced ? 'Status: synced' : '
') + ); + }); + }); + }); +}; \ No newline at end of file diff --git a/complete-app/server.js b/complete-app/server.js new file mode 100644 index 0000000..3aee5c4 --- /dev/null +++ b/complete-app/server.js @@ -0,0 +1,96 @@ +var express = require('express'); +var app = express(); +var http = require('http').Server(app); +var io = require('socket.io')(http); +var pg = require("pg"); +var credentials = require("./credentials"); + +app.use(express.static('public')); + +app.enable('trust proxy'); + +app.use(function(req, res, next) { +    res.header("Access-Control-Allow-Origin", "*"); +    res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); +    next(); +}); + +app.use(express.static(path.join(__dirname, '/public/'))); + +app.get('/', function (req, res, next) { + + // var options = { + // root: __dirname + '/public/', + // dotfiles: 'deny', + // headers: { + // 'x-timestamp': Date.now(), + // 'x-sent': true + // } + // }; + + var fileName = 'index.html'; + res.sendFile(fileName, options, function (err) { + if (err) { + console.log(err); + res.status(err.status).end(); + } + else { + console.log('Sent:', fileName); + } + }); + +}) + +io.on('connection', function(socket){ + console.log('a user connected'); + + socket.on('new connection', function(point) { + console.log('I got a new connection'); + var sql = ''; + queryPg( + sql, + [point._id, point.descrip, point.lat, point.lng], + function(err, result) {} + ); + io.emit('many points', points); + }); + + socket.on('new point', function(point) { + console.log('I got a new point that has ' + point.descrip); + var sql = 'INSERT INTO points (id, descrip, lat, lng) VALUES ($1, $2, $3, $4)'; + queryPg( + sql, + [point._id, point.descrip, point.lat, point.lng], + function(err, result) {} + ); + io.emit('new point', point); + }); +}); + +var queryPg = function(sql, params, callback) { + pg.connect( + "postgres://" + creds.pg.user + "@" + creds.pg.host + ":" + creds.pg.port + "/" + creds.pg.db, + function(err, client, done) { + if (err) { + console.log("error", "error connecting - " + err); + callback(err); + } + else { + var query = client.query(sql, params, function(err, result) { + done(); + if (err) { + console.log("error", err); + callback(err); + } + else { + callback(null, result); + } + }.bind(this)); + //console.log(query.text, query.values); + } + }.bind(this)); +}; + +http.listen(3000, function(){ + console.log('listening on *:3000'); +});