Commit 2522c540 authored by Adrien Pavie's avatar Adrien Pavie
Browse files

Background can be changed interactively

parent 9cdb7749
......@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Added
- If no map position is provided when loading an existing sketch, it zooms automatically to existing shapes
- Map background can be changed using left menu
### Changed
- Drawn shapes are now more or less wide according to zoom level
......
......@@ -2,7 +2,7 @@
![Logo](src/website/img/logo.400.png)
Gribrouillon makes drawing over web maps easy. You can create sketchy maps in seconds, on mobile or desktop. [Give it a try !](https://gribrouillon.pavie.info/)
Gribrouillon ([gribrouillon.fr](https://gribrouillon.fr)) makes drawing over web maps easy. You can create sketchy maps in seconds, on mobile or desktop. [Give it a try !](https://gribrouillon.fr/)
## Deploy your own instance
......
This diff is collapsed.
......@@ -7,7 +7,7 @@
"start": "npm run build && node src/index.js",
"start:prod": "npm run build && while [ true ]; do node src/index.js; sleep 5; done",
"build": "npm run build:assets && npm run build:js",
"build:assets": "mkdir -p build && cp -r src/website/*.html src/website/*.css src/website/img node_modules/bootstrap/dist/css/bootstrap.min.css node_modules/bootstrap/dist/js/bootstrap.min.js node_modules/jquery-slim/dist/jquery.slim.min.js node_modules/leaflet/dist/leaflet.js node_modules/leaflet/dist/leaflet.css node_modules/leaflet-hash/leaflet-hash.js node_modules/font-awesome/css/font-awesome.min.css node_modules/font-awesome/fonts node_modules/qrcode-svg/dist/qrcode.min.js build/",
"build:assets": "mkdir -p build && cp -r src/website/*.html src/website/*.css src/website/img node_modules/bootstrap/dist/css/bootstrap.min.css node_modules/bootstrap/dist/js/bootstrap.min.js node_modules/jquery-slim/dist/jquery.slim.min.js node_modules/leaflet/dist/leaflet.js node_modules/leaflet/dist/leaflet.css node_modules/leaflet-hash/leaflet-hash.js node_modules/font-awesome/css/font-awesome.min.css node_modules/font-awesome/fonts node_modules/qrcode-svg/dist/qrcode.min.js node_modules/leaflet-bing-layer/leaflet-bing-layer.min.js build/",
"build:js": "simplifyify --minify --outfile \"build/*.min.js\" src/website/*.js",
"clean": "rm -rf ./build"
},
......@@ -25,6 +25,7 @@
"author": "Adrien Pavie",
"license": "AGPL-3.0",
"dependencies": {
"@turf/boolean-contains": "^6.0.1",
"bootstrap": "^4.4.1",
"compression": "^1.7.4",
"cors": "^2.8.5",
......@@ -32,9 +33,12 @@
"font-awesome": "^4.7.0",
"jquery-slim": "^3.0.0",
"leaflet": "^1.6.0",
"leaflet-bing-layer": "^3.3.1",
"leaflet-hash": "^0.2.1",
"pg": "^7.14.0",
"qrcode-svg": "^1.1.0",
"request": "^2.88.2",
"request-promise-native": "^1.0.8",
"socket.io": "^2.3.0"
},
"devDependencies": {
......
......@@ -46,8 +46,8 @@ WHERE sketch = $1
`, [ sid ])
.then(result2 => {
geojson.features = result2.rows[0].features;
const bbox = result2.rows[0].envelope.coordinates[0];
geojson.bbox = [ bbox[0][0], bbox[0][1], bbox[2][0], bbox[2][1] ];
const bbox = result2.rows[0].envelope && result2.rows[0].envelope.coordinates[0];
geojson.bbox = bbox && [ bbox[0][0], bbox[0][1], bbox[2][0], bbox[2][1] ];
return geojson;
});
}
......
......@@ -6,6 +6,8 @@ const CONFIG = require('../config.json');
const express = require('express');
const cors = require('cors');
const db = require("./db");
const request = require("request-promise-native");
const booleanContains = require("@turf/boolean-contains").default;
// Init API
const port = process.env.PORT || 3000;
......@@ -17,6 +19,77 @@ app.options('*', cors());
app.use(require("compression")());
/*
* Retrieve editor layer index
*/
let backgroundLayers = [
{ id: "MAPNIK", name: "OpenStreetMap (standard)", type: "tms", category: "osmbasedmap", url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: "© OpenStreetMap contributors, CC-BY-SA 2.0", geometry: null }
];
const exclusionList = [ "EOXAT2018CLOUDLESS" ];
const fixTypes = { "Bing": "photo", "fr.ign.bdortho": "photo" };
function retrieveBackgroundLayers() {
request("https://osmlab.github.io/editor-layer-index/imagery.geojson")
.then(txt => JSON.parse(txt))
.then(geojson => {
const toKeep = geojson.features.filter(layer => {
const p = layer.properties;
return p.id && p.url && p.name
&& !exclusionList.includes(p.id)
&& ["tms", "wms", "bing", "wmts"].includes(p.type)
&& (!p.category || !["qa", "elevation", "other"].includes(p.category))
&& (!p.min_zoom || p.min_zoom <= 5)
&& (!p.max_zoom || p.max_zoom >= 14)
&& !p.overlay;
})
.map(layer => {
const p = layer.properties;
let subdomains = undefined;
if(p.url.includes("{switch")) {
const search = /{switch:(.+?)}/g.exec(p.url);
if(search.length >= 1) { subdomains = search[1].split(","); }
}
let attribution = p.attribution && p.attribution.text ? p.attribution.text : undefined;
if(p.attribution && p.attribution.url) {
attribution = `<a href="${p.attribution.url}" target="_blank">${attribution || "License"}</a>`;
}
return {
id: p.id,
name: p.name,
type: p.type,
category: fixTypes[p.id] || p.category || "other",
url: p.url
.replace(/{zoom}/g, '{z}')
.replace(/{switch:.+?}/g, '{s}'),
attribution: attribution,
geometry: layer.geometry,
best: p.best || false,
minZoom: p.min_zoom || 0,
maxZoom: p.max_zoom || 18,
subdomains: subdomains
};
});
if(toKeep.length > 0) {
backgroundLayers = toKeep;
console.log("Updated background layers");
}
})
.catch(e => {
console.error(e);
console.error("Can't retrieve background layers");
});
}
setTimeout(retrieveBackgroundLayers, 15000); // Delay first call
setInterval(retrieveBackgroundLayers, 1000*60*60*24); // Once per day
/*
* List of routes
*/
......@@ -38,6 +111,13 @@ app.use((req, res) => {
*/
io.on("connection", socket => {
// Find available backgrounds
socket.on("background_find", coordinates => {
socket.emit("background_found", backgroundLayers.filter(layer => (
layer.geometry === null || booleanContains(layer.geometry, { type: "Point", coordinates: coordinates })
)));
});
// Create new sketch
socket.on("sketch_create", options => {
db.createSketch(options)
......
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg2"
viewBox="0 0 191.99016 191.99022"
height="128"
width="128"
sodipodi:docname="bing.svg"
inkscape:version="0.92.4 5da689c313, 2019-01-14">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1015"
id="namedview6"
showgrid="false"
units="px"
inkscape:zoom="2.8284271"
inkscape:cx="84.09691"
inkscape:cy="99.28118"
inkscape:window-x="0"
inkscape:window-y="452"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<metadata
id="metadata9">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs7" />
<path
style="fill:#01aa9a;fill-opacity:1;stroke:none;stroke-width:0.45649841"
d="M 25.694342,1.4999233 V 164.92636 L 60.388221,190.49027 166.29585,134.79746 V 93.712603 L 81.387148,67.692194 96.908095,104.66857 121.10251,116.53753 64.953205,147.12292 V 12.455886 Z"
id="Bing"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccc" />
</svg>
......@@ -31,6 +31,12 @@
<nav>
<img src="img/logo.svg" alt="Gribrouillon" style="width: 90%" />
<hr />
<ul class="nav flex-column">
<li class="nav-item">
<a id="btn-background" class="nav-link" data-toggle="modal" data-target="#backgroundModal"><i class="fa fa-map" aria-hidden="true"></i> Change background</a>
</li>
</ul>
<hr />
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="about.html"><i class="fa fa-question-circle" aria-hidden="true"></i> About</a>
......@@ -143,11 +149,27 @@
</div>
</div>
<div class="modal fade" id="backgroundModal" tabindex="-1" role="dialog" aria-labelledby="backgroundModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="backgroundModalLabel"><i class="fa fa-map" aria-hidden="true"></i> Choose map background</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div id="background-picker" class="modal-body d-flex flex-wrap justify-content-center">
</div>
</div>
</div>
</div>
<script src="jquery.slim.min.js"></script>
<script src="bootstrap.min.js"></script>
<script src="leaflet.js"></script>
<script src="leaflet-hash.js"></script>
<script src="qrcode.min.js"></script>
<script src="leaflet-bing-layer.min.js"></script>
<script src="socket.io/socket.io.js"></script>
<script src="index.min.js"></script>
</body>
......
// Constants
const GEOCODER_URL = "https://nominatim.openstreetmap.org";
const BING_API_KEY = "AhzbK3kqqkO2lKw7imz7ofxWa5K_qPIFaktbhJhL-SmI_zQK2K36RaG1ULrXlmHZ";
const COLORS = [
["white", "#d2d7d3", "#bfbfbf", "#67809f", "rgb(111, 111, 111)", "black"],
["#ffffcc", "#f2d984", "#f0ff00", "#f5e51b", "#f4d03f", "#f7ca18"],
......@@ -12,7 +13,9 @@ const COLORS = [
// Script-wide variables
let map, sid, shapesLayer, tmpShapeCoordinates,
tmpShapeLayer, timerGeocoder, noStartLocation;
tmpShapeLayer, timerGeocoder, noStartLocation,
backgroundLayers, tilesLayer, backgroundLayersPosition,
useBackgroundFromURL;
const drawOpts = {
color: "red",
penSize: 20,
......@@ -20,6 +23,7 @@ const drawOpts = {
};
const shapesLayersById = {};
let shapeLayerTmpId = 0;
let selectedBackgroundLayer = "MAPNIK";
const shapesLayersWithoutId = {};
const socket = io();
const domLoader = document.getElementById("loader");
......@@ -28,6 +32,7 @@ const domColorPicker = document.getElementById("color-picker");
const domBtnColor = document.getElementById("btn-color");
const domBtnUndo = document.getElementById("btn-undo");
const domSearchAddress = document.getElementById("searchbar");
const domBackgroundPicker = document.getElementById("background-picker");
......@@ -85,6 +90,14 @@ L.Polyline.include({
* API communication
*/
/**
* Find available background layers
*/
function findBackgroundLayers() {
backgroundLayersPosition = map.getCenter();
socket.emit("background_find", [ backgroundLayersPosition.lng, backgroundLayersPosition.lat ]);
}
/**
* Loads an existing sketch
* @param {string} id The sketch ID
......@@ -95,9 +108,10 @@ function loadSketch(id) {
socket.on("sketch_loaded", geojson => {
sid = id;
if(noStartLocation) {
if(noStartLocation && geojson.bbox) {
map.fitBounds([ [ geojson.bbox[1], geojson.bbox[0] ], [ geojson.bbox[3], geojson.bbox[2] ] ]);
}
loadBackgroundFromURL();
showLoader(false);
showShapes(geojson);
listenAPIChanges();
......@@ -117,20 +131,13 @@ function loadSketch(id) {
function registerSketch() {
showLoader(true);
changeMode("nav");
loadBackgroundFromURL();
socket.on("sketch_created", id => {
sid = id;
showLoader(false);
listenAPIChanges();
// Update URL
const newUrl = [
location.protocol, '//', location.host,
location.pathname,
"?sid="+sid,
location.hash.length > 0 ? location.hash : ""
].join('');
window.history.replaceState({}, "", newUrl);
setUrlParameter("sid", sid);
});
socket.on("sketch_creation_failed", () => {
......@@ -200,6 +207,36 @@ function listenAPIChanges() {
socket.on("shape_deletion_done", fid => {
// console.log("Deleted shape", fid);
});
// Callback after background search
socket.on("background_found", layers => {
backgroundLayers = layers.sort((a,b) => {
const diffCat = b.category.localeCompare(a.category);
if(diffCat === 0) {
if(a.best && b.best) { return 0; }
else if(a.best && !b.best) { return -1; }
else { return 1; }
}
else {
return diffCat;
}
});
if(useBackgroundFromURL) {
const tile = backgroundLayers.find(bl => bl.id === useBackgroundFromURL);
if(tile) {
useBackgroundFromURL = null;
showTiles(tile);
}
else {
console.log("Background", useBackgroundFromURL, "not available in this area");
useBackgroundFromURL = null;
}
}
else {
updateBackgroundsList();
}
});
}
/**
......@@ -462,6 +499,159 @@ function updateSharingLinks() {
}).svg();
}
/**
* Replace background tiles by given ones
* @param {Object} tile The imagery info
*/
function showTiles(tile) {
if(selectedBackgroundLayer === tile.id) { return null; }
selectedBackgroundLayer = tile.id;
setUrlParameter("bg", tile.id);
map.removeLayer(tilesLayer);
switch(tile.type) {
case "tms":
tilesLayer = L.tileLayer(tile.url, tile);
break;
case "wms":
case "wmts":
tilesLayer = L.tileLayer.wms(tile.url, tile);
break;
case "bing":
tilesLayer = L.tileLayer.bing(BING_API_KEY);
break;
}
tilesLayer.addTo(map);
}
function long2tile(lon,zoom) {
return (Math.floor((lon+180)/360*Math.pow(2,zoom)));
}
function lat2tile(lat,zoom) {
return (Math.floor((1-Math.log(Math.tan(lat*Math.PI/180) + 1/Math.cos(lat*Math.PI/180))/Math.PI)/2 *Math.pow(2,zoom)));
}
function tileToUrl(tile) {
const center = map.getCenter();
const zoom = Math.min(16, map.getZoom()+1);
const y = lat2tile(center.lat, zoom);
return tile.url
.replace(/{x}/g, long2tile(center.lng, zoom))
.replace(/{y}/g, y)
.replace(/{-y}/g, y)
.replace(/{z}/g, zoom)
.replace(/{proj}/g, 'EPSG:3857')
.replace(/{width}/g, 256)
.replace(/{height}/g, 256)
.replace(/{bbox}/g, map.getBounds().toBBoxString())
.replace(/{s}/g, tile.subdomains && tile.subdomains.length > 0 ? tile.subdomains[0] : "a");
}
/**
* Update list of background maps
*/
function updateBackgroundsList() {
if(!backgroundLayers || !backgroundLayersPosition || backgroundLayersPosition.distanceTo(map.getCenter()) > 15000) {
domBackgroundPicker.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="sr-only">Loading...</span></div>';
findBackgroundLayers();
}
else {
domBackgroundPicker.innerHTML = '';
// Create entry for each tile
backgroundLayers.forEach(layer => {
const domBgl = document.createElement("div");
domBgl.classList.add("card");
domBgl.setAttribute("data-dismiss", "modal");
if(layer.id === selectedBackgroundLayer) {
domBgl.classList.add("card-selected");
}
// Image
const domBglImg = document.createElement("img");
switch(layer.type) {
case "tms":
case "wms":
case "wmts":
domBglImg.src = tileToUrl(layer);
break;
case "bing":
domBglImg.src = "img/bing.svg";
break;
default:
domBglImg.src = "img/badge.svg";
}
domBglImg.alt = "";
domBglImg.title = layer.name;
domBglImg.classList.add("card-img-top");
domBgl.appendChild(domBglImg);
const domBglDiv = document.createElement("div");
domBglDiv.classList.add("card-body", "p-2");
domBglDiv.innerHTML = layer.name.length > 30 ? layer.name.substring(0, 27)+"..." : layer.name;
domBgl.appendChild(domBglDiv);
domBackgroundPicker.appendChild(domBgl);
// Click event
domBgl.addEventListener("click", () => showTiles(layer));
});
}
}
/**
* Force loading background if bg param exists in URL
*/
function loadBackgroundFromURL() {
const params = readUrlParameters();
if(params.bg && params.bg !== "MAPNIK") {
useBackgroundFromURL = params.bg;
findBackgroundLayers();
}
}
/**
* Function to hide left menu
*/
function hideLeftMenu() {
document.getElementById("left-menu").classList.add("d-none");
}
/**
* Read all params from URL
*/
function readUrlParameters() {
return Object.fromEntries(
location.search
.replace(/^\?/, '')
.split("&")
.map(kv => kv.split("=").map(decodeURIComponent))
);
}
/**
* Change a parameter in URL
* @param {string} k The key
* @param {string} v The value
*/
function setUrlParameter(k, v) {
// Read current parameters
const params = readUrlParameters();
params[k] = v;
const newSearch = Object.entries(params).map(e => e.map(encodeURIComponent).join("=")).join("&");
const newUrl = [
location.protocol, '//', location.host,
location.pathname,
"?"+newSearch,
location.hash.length > 0 ? location.hash : ""
].join('');
window.history.replaceState({}, "", newUrl);
}
/**
* Initializes the editor, with default settings
*/
......@@ -470,20 +660,23 @@ function init() {
document.getElementById("btn-menu").addEventListener("click", () => {
document.getElementById("left-menu").classList.remove("d-none");
});
document.getElementById("left-menu-mask").addEventListener("click", () => {
document.getElementById("left-menu").classList.add("d-none");
});
document.getElementById("left-menu-mask").addEventListener("click", hideLeftMenu);
document.getElementById("btn-background").addEventListener("click", hideLeftMenu);
// Buttons
document.getElementById("btn-valid").addEventListener("click", () => {
changeMode("nav");
});
document.getElementById("btn-edit").addEventListener("click", () => {
changeMode("draw");
});
document.getElementById("btn-valid").addEventListener("click", () => changeMode("nav"));
document.getElementById("btn-edit").addEventListener("click", () => changeMode("draw"));
domInputPenSize.addEventListener("change", setPenSize);
domInputPenSize.addEventListener("input", setPenSize);
domBtnUndo.addEventListener("click", undoShapeDrawn);
$("#sharingModal").on("show.bs.modal", updateSharingLinks);
$("#backgroundModal").on("show.bs.modal", updateBackgroundsList);
document.getElementById("share-url-copy").addEventListener("click", e => {
const txtField = document.getElementById("share-url");
txtField.select();
txtField.setSelectionRange(0, 99999);
document.execCommand("copy");
});
// Color picker
COLORS.forEach(range => {
......@@ -514,7 +707,7 @@ function init() {
noStartLocation = true;
}
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
tilesLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
})
......@@ -526,19 +719,10 @@ function init() {
domSearchAddress.value = "";
domSearchAddress.addEventListener("input", searchAddress);
// Share links
$("#sharingModal").on("show.bs.modal", updateSharingLinks);
document.getElementById("share-url-copy").addEventListener("click", e => {
const txtField = document.getElementById("share-url");
txtField.select();
txtField.setSelectionRange(0, 99999);
document.execCommand("copy");
});
// Read sketch ID from URL
const sidTxts = /[?&]sid=([A-Za-z0-9]+)[^A-Za-z0-9]?/g.exec(document.location.search);
if(sidTxts && sidTxts.length > 1) {
loadSketch(sidTxts[1]);
const sidTxt = readUrlParameters().sid;
if(sidTxt && sidTxt.trim().length > 0) {
loadSketch(sidTxt.trim());
}
else {
registerSketch();
......
......@@ -117,6 +117,7 @@
#left-menu li a {
color: #555;
font-size: 1.1rem;
cursor: pointer;
}