/* globals $, geo, utils */
/* globals $, geo, utils */
This is extensively based on the Annotation example.
Always positive modulo function
function modulo(a, b) {
return ((a % b) + b) % b;
}
Get url query parameters
var query = utils.getQuery();
If a flag is set, add some extra tile sources. Make sure you have the appropriate licenses before doing this.
if (query.extra) {
This is a proof of concept; it may require licensing, or at least registering with Microsoft
geo.osmLayer.tileSources['bing-satellite'] = {
url: (x, y, z, s) => {
s = s[modulo(x + y + z, s.length)];
let tile = '';
for (; z > 0; z -= 1) {
const d = (x % 2) + (y % 2) * 2;
tile = '' + d + tile;
x = Math.floor(x / 2);
y = Math.floor(y / 2);
}
return `http://ecn.t${s}.tiles.virtualearth.net/tiles/a${tile}.jpeg?g=5000`;
},
attribution: '<a href="https://www.microsoft.com/maps/product/terms.html">Microsoft</a> Virtual Earth',
subdomains: '0123',
minLevel: 1,
maxLevel: 19,
name: 'Bing Satellite'
};
This requires that you have an appropriate Esri ArcGIS license to use. It is possible that its use might be allowed in certain uses via https://www.arcgis.com/home/item.html?id=10df2279f9684e4a9f6a7f08febac2a9
geo.osmLayer.tileSources['arcgis-satellite'] = {
url: 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
attribution: '<a href="https://www.esri.com/en-us/search/?q=world%20imagery">Esri World Imagery</a>',
minLevel: 0,
maxLevel: 19,
name: 'ArcGIS Satellite'
};
}
Add a blank tile for removing the map
geo.osmLayer.tileSources.false = {
url: '/data/white.jpg',
attribution: '',
name: 'None'
};
Fill select drop down
var options = geo.osmLayer.tileSources;
for (const option in options) {
var newOption = document.createElement('option');
newOption.value = option;
newOption.text = options[option].name ? options[option].name : option;
document.getElementById('basemap').appendChild(newOption);
}
var map, mapLayer, layer, fromButtonSelect, fromGeojsonUpdate;
Set controls based on query parameters
$('#basemap').val(query.basemap || 'osm');
$('#mapurl').val(query.mapurl || '');
$('#mapurl').toggleClass('hidden', $('#basemap').val() !== 'custom');
$('#distunit').val(query.distunit || 'decmiles');
$('#areaunit').val(query.areaunit || 'decmiles');
$('#clickmode').val(query.clickmode || 'edit');
$('#keepadding').prop('checked', query.keepadding === 'true');
$('#showLabels').prop('checked', query.labels !== 'false');
if (query.lastannotation) {
$('.annotationtype button').removeClass('lastused');
$('.annotationtype button#' + query.lastannotation).addClass('lastused');
}
if (query.hide) {
$('#controls').addClass('reduced');
}
You can set the initial annotations via a query parameter. If the query parameter ‘save=true’ is specified, the query will be updated with the geojson. This can become too long for some browsers.
var initialGeoJSON = query.geojson;
respond to changes in our controls
$('#controls').on('change', change_controls);
$('#geojson[type=textarea]').on('input propertychange', change_geojson);
$('#controls').on('click', 'a', select_control);
$('.annotationtype button').on('click', select_annotation);
$('#editdialog').on('submit', edit_update);
$('#show,#hide').on('click', toggle_hide);
$('#controls').toggleClass('no-controls', query.controls === 'false');
Default to near Fresno unless a position is specified. If there is existing data, we frame the available data instead.
map = geo.map({
node: '#map',
center: {
x: query.x !== undefined ? +query.x : -119.150,
y: query.y !== undefined ? +query.y : 36.712
},
max: 21,
zoom: query.zoom ? +query.zoom : 10,
rotation: query.rotation ? +query.rotation * Math.PI / 180 : 0
});
Create a tile layer
mapLayer = map.createLayer('osm', {
url: query.basemap === 'custom' ? query.mapurl || '' : undefined,
source: query.basemap === 'custom' ? undefined : query.basemap
});
Create an annotation layer
layer = map.createLayer('annotation', {
renderer: query.renderer ? (query.renderer === 'html' ? null : query.renderer) : undefined,
annotations: query.renderer ? undefined : geo.listAnnotations(),
showLabels: query.labels === 'false' ? false : Object.keys(geo.annotation.state),
clickToEdit: !query.clickmode || query.clickmode === 'edit'
});
We have to hook the internal _updateLabels method so we can label vertices during editing.
const s__updateLabels = layer._updateLabels;
layer._updateLabels = (labels) => {
layer.annotations().forEach((annotation, idx) => {
if ([geo.annotation.state.create, geo.annotation.state.edit].indexOf(annotation.state()) >= 0) {
labelVertices(annotation, idx, labels);
}
});
s__updateLabels(labels);
return layer;
};
Bind to the mouse click and annotation mode events
layer.geoOn(geo.event.mouseclick, mouseClickToStart);
layer.geoOn(geo.event.annotation.mode, handleModeChange);
layer.geoOn(geo.event.annotation.add, handleAnnotationChange);
layer.geoOn(geo.event.annotation.update, handleAnnotationChange);
layer.geoOn(geo.event.annotation.remove, handleAnnotationChange);
layer.geoOn(geo.event.annotation.state, handleAnnotationChange);
Add a scale widget
var uiLayer = map.createLayer('ui');
var scaleWidget = uiLayer.createWidget('scale', {
position: {right: 10, bottom: 10},
units: query.distunit === 'si' ? 'si' : 'miles'
});
map.draw();
Pick which button is initially highlighted based on query parameters.
if (query.lastused || query.active) {
if (query.active) {
layer.mode(query.active);
} else {
$('.annotationtype button').removeClass('lastused active');
$('.annotationtype button#' + query.lastused).addClass('lastused');
}
}
If we have geojson as a query parameter, populate our annotations
if (initialGeoJSON) {
layer.geojson(initialGeoJSON, true);
if (query.x === undefined || query.y === undefined) {
const range = {};
layer.annotations().forEach(a => {
let ptlist = a.coordinates();
if (ptlist.outer) {
ptlist = [ptlist.outer.slice()].concat((ptlist.inner || []).map((l) => l.slice()));
} else {
ptlist = [ptlist.slice()];
}
ptlist.forEach((pts) => {
pts.forEach(pt => {
if (range.left === undefined || pt.x < range.left) {
range.left = pt.x;
}
if (range.bottom === undefined || pt.y < range.bottom) {
range.bottom = pt.y;
}
if (range.right === undefined || pt.x > range.right) {
range.right = pt.x;
}
if (range.top === undefined || pt.y > range.top) {
range.top = pt.y;
}
});
});
});
map.bounds(range);
map.zoom(map.zoom() - 0.25);
}
}
/**
* When the mouse is clicked, switch to adding an annotation if appropriate.
*
* @param {geo.event} evt geojs event.
*/
function mouseClickToStart(evt) {
if (evt.handled || query.clickmode !== 'add') {
return;
}
if (evt.buttonsDown.left) {
if ($('.annotationtype button.lastused').hasClass('active') && query.keepadding === 'true') {
return;
}
select_button('.annotationtype button.lastused');
} else if (evt.buttonsDown.right) {
select_button('.annotationtype button#' +
$('.annotationtype button.lastused').attr('next'));
}
}
/**
* Handle changes to our controls.
*
* @param evt jquery evt that triggered this call.
*/
function change_controls(evt) {
var ctl = $(evt.target),
param = ctl.attr('param-name'),
value = ctl.val();
if (ctl.is('[type="checkbox"]')) {
value = ctl.is(':checked') ? 'true' : 'false';
}
if (value === '' && ctl.attr('placeholder')) {
value = ctl.attr('placeholder');
}
if (!param || value === query[param]) {
return;
}
switch (param) {
case 'basemap':
if (value === 'custom') {
mapLayer.url(query.mapurl || '').attribution('').draw();
} else {
mapLayer.source(value).draw();
}
$('#mapurl').toggleClass('hidden', value !== 'custom');
break;
case 'mapurl':
if (query.basemap === 'custom') {
mapLayer.url(value || '').attribution('').draw();
}
break;
case 'distunit':
query[param] = value;
layer.annotations().forEach(a => a.modified());
layer.draw();
handleAnnotationChange();
scaleWidget.options('units', query.distunit === 'si' ? 'si' : 'miles');
break;
case 'areaunit':
query[param] = value;
layer.annotations().forEach(a => a.modified());
layer.draw();
handleAnnotationChange();
break;
case 'labels':
layer.options('showLabels', value === 'false' ? false : Object.keys(geo.annotation.state));
layer.draw();
break;
case 'clickmode':
layer.options('clickToEdit', value === 'edit');
layer.draw();
break;
}
query[param] = value;
if (value === '' || (ctl.attr('placeholder') &&
value === ctl.attr('placeholder'))) {
delete query[param];
}
Update our query parameters, os when you reload the page it is in the same state
utils.setQuery(query);
}
/**
* Toggle showing the full controls.
*/
function toggle_hide() {
if (query.hide) {
delete query.hide;
} else {
query.hide = 'true';
}
$('#controls').toggleClass('reduced', query.hide);
utils.setQuery(query);
}
/**
* Handle changes to the geojson.
*
* @param evt jquery evt that triggered this call.
*/
function change_geojson(evt) {
var ctl = $(evt.target),
value = ctl.val();
When we update the geojson from the textarea control, raise a flag so we (a) ignore bad geojson, and (b) don’t replace the user’s geojson with the auto-generated geojson
fromGeojsonUpdate = true;
var result = layer.geojson(value, 'update');
if (query.save !== 'false' && result !== undefined) {
var geojson = layer.geojson();
query.geojson = geojson ? JSON.stringify(geojson) : undefined;
utils.setQuery(query);
}
fromGeojsonUpdate = false;
}
/**
* Handle selecting an annotation button.
*
* @param evt jquery evt that triggered this call.
*/
function select_annotation(evt) {
select_button(evt.target);
}
/**
* Select an annotation button by jquery selector.
*
* @param {object} ctl a jquery selector or element.
*/
function select_button(ctl) {
ctl = $(ctl);
var wasactive = ctl.hasClass('active'),
id = ctl.attr('id');
fromButtonSelect = true;
layer.mode(wasactive ? null : id);
fromButtonSelect = false;
}
/**
* When the annotation mode changes, update the controls to reflect it.
*
* @param {geo.event} evt a geojs mode change event.
*/
function handleModeChange(evt) {
Highlight the current buttons based on the current mode
var mode = layer.mode();
$('.annotationtype button').removeClass('active');
if (mode) {
$('.annotationtype button').removeClass('lastused active');
$('.annotationtype button#' + mode).addClass('lastused active');
}
$('#instructions').attr(
'annotation', $('.annotationtype button.active').attr('id') || 'none');
query.active = $('.annotationtype button.active').attr('id') || undefined;
query.lastused = query.active ? undefined : $('.annotationtype button.lastused').attr('id');
utils.setQuery(query);
If we are in keep-adding mode, and the mode changed to null, and that wasn’t caused by clicking the button, reenable the annotation mode.
if (!mode && !fromButtonSelect && query.keepadding === 'true') {
layer.mode($('.annotationtype button.lastused').attr('id'));
}
}
/**
* Calculate the length of an annotation.
*
* @param {geo.annotation} annotation The annotation.
* @returns {number} The length in meters or `undefined`.
*/
function annotationLength(annotation) {
let dist = 0;
const gcs = annotation.layer().map().ingcs();
let ptlist = annotation.coordinates(gcs);
if (ptlist.outer) {
ptlist = [ptlist.outer.slice()].concat((ptlist.inner || []).map((l) => l.slice()));
} else {
ptlist = [ptlist.slice()];
}
ptlist.forEach((pts) => {
if (pts.length < 2) {
return;
}
if (['polygon', 'rectangle'].indexOf(annotation.type()) >= 0) {
pts = pts.slice();
pts.push(pts[0]);
}
for (let i = 0; i < pts.length - 1; i += 1) {
const partial = geo.transform.vincentyDistance(pts[i], pts[i + 1], gcs);
if (partial) {
dist += partial.distance;
}
}
});
return dist;
}
/**
* Calculate the area of an annotation. Use an equal-area projection centered
* on the first vertex and then a simple 2-D polygon area calculation.
*
* @param {geo.annotation} annotation The annotation.
* @returns {number} The area in square meters or `undefined`.
*/
function annotationArea(annotation) {
let area = 0,
ptlist = annotation.coordinates('EPSG:4326');
if (ptlist.outer) {
ptlist = [ptlist.outer];
} else {
ptlist = [ptlist];
}
if (ptlist[0].length < 3 || ['polygon', 'rectangle'].indexOf(annotation.type()) < 0) {
return;
}
By using an equal-area projection centered at one of the vertices, the area calculation can be done with a simple formula. For polygons with long edges, this won’t be accurate, however.
const gcs = `+proj=laea +lat_0=${ptlist[0][0].y} +lon_0=${ptlist[0][0].x} +x_0=0 +y_0=0 +a=6371007.181 +b=6371007.181 +units=m +no_defs`;
ptlist = annotation.coordinates(gcs);
if (ptlist.outer) {
ptlist = [ptlist.outer.slice()].concat((ptlist.inner || []).map((l) => l.slice()));
} else {
ptlist = [ptlist.slice()];
}
ptlist.forEach((pts, idx) => {
let looparea = 0;
pts.push(pts[0]);
for (let i = 0; i < pts.length - 1; i += 1) {
looparea += (pts[i + 1].y - pts[i].y) * (pts[i + 1].x + pts[i].x) / 2;
}
area += Math.abs(looparea) * (idx ? -1 : 1);
});
return area;
}
/**
* Add the units to the name of the annotation if appropriate.
*
* @param {geo.annotation} annotation The annotation.
*/
function modifyAnnotation(annotation) {
if (annotation._changed) {
return;
}
annotation._changed = true;
var s_labelRecord = annotation.labelRecord;
Hook into the labelRecord method to add the distance and area to the label
annotation.labelRecord = () => {
annotation.options('showLabel', query.labels === 'false' ? false : Object.keys(geo.annotation.state));
let dist = annotationLength(annotation),
area = annotationArea(annotation);
var result = s_labelRecord();
if (result) {
if (dist) {
dist = geo.gui.scaleWidget.formatUnit(dist, query.distunit || 'decmiles');
result.text += ' - ' + dist;
}
if (area) {
area = geo.gui.scaleWidget.formatUnit(area, query.areaunit || 'decmiles', geo.gui.scaleWidget.areaUnitsTable);
result.text += ' - ' + area;
}
}
if (result && result.text !== annotation._lastResultText) {
map.scheduleAnimationFrame(handleAnnotationChange);
annotation._lastResultText = result.text;
}
return result;
};
}
/**
* Label all of the vertices and centers of edges of an annotation.
*
* @param {geo.annotation} annotation The annotation.
* @param {number} annotationIndex The position of the annotation.
* @param {object[]} labels The labels data array to extend.
*/
function labelVertices(annotation, annotationIndex, labels) {
const gcs = annotation.layer().map().gcs();
let ptlist = annotation.coordinates(gcs);
if (ptlist.outer) {
ptlist = [ptlist.outer.slice()].concat((ptlist.inner || []).map((l) => l.slice()));
} else {
ptlist = [ptlist.slice()];
}
ptlist.forEach((pts) => {
for (let i = 1; i < pts.length; i += 1) {
if (pts[i].x === pts[i - 1].x && pts[i].y === pts[i - 1].y) {
pts.splice(i, 1);
}
}
if (pts.length < 3) {
return;
}
if (['polygon', 'rectangle'].indexOf(annotation.type()) >= 0) {
pts.push(pts[0]);
}
var style = labels[annotationIndex].style || {};
var dist = [], tally = [0];
for (let i = 0; i < pts.length - 1; i += 1) {
const value = geo.transform.vincentyDistance(pts[i], pts[i + 1], gcs);
dist.push(value ? value.distance : 0);
}
dist.forEach((d, i) => {
tally.push(tally[i] + d);
});
pts.forEach((p, i) => {
if (i) {
labels.push({
text: geo.gui.scaleWidget.formatUnit(tally[i], query.distunit || 'decmiles') || '',
position: p,
style: Object.assign({}, style, {offset: {x: 12, y: 0}, textAlign: 'left'})
});
}
if (i !== pts.length - 1) {
labels.push({
text: geo.gui.scaleWidget.formatUnit(tally[tally.length - 1] - tally[i], query.distunit || 'decmiles') || '',
position: p,
style: Object.assign({}, style, {offset: {x: -12, y: 0}, textAlign: 'right'})
});
const p1 = {x: (pts[i + 1].x + p.x) / 2, y: (pts[i + 1].y + p.y) / 2};
labels.push({
text: geo.gui.scaleWidget.formatUnit(dist[i], query.distunit || 'decmiles') || '',
position: p1,
style: Object.assign({}, style, {offset: {x: 10, y: 0}, textAlign: 'left'})
});
}
});
});
}
/**
* When an annotation is created or removed, update our list of annotations.
*
* @param {geo.event} evt a geojs mode change event.
*/
function handleAnnotationChange(evt) {
var annotations = layer.annotations();
var ids = annotations.map(function (annotation) {
return annotation.id();
});
var present = [];
$('#annotationlist .entry').each(function () {
var entry = $(this);
if (entry.attr('id') === 'sample') {
return;
}
var id = entry.attr('annotation-id');
Remove deleted annotations
if (!ids.includes(id)) {
entry.remove();
return;
}
present.push(id);
Update existing elements
entry.find('.entry-name').text(layer.annotationById(id).name());
});
let totaldist = 0, totalarea = 0;
Add if new and fully created
$.each(ids, function (idx, id) {
var annotation = layer.annotationById(id);
let dist = annotationLength(annotation),
area = annotationArea(annotation);
if (dist) { totaldist += dist; }
if (area) { totalarea += area; }
dist = geo.gui.scaleWidget.formatUnit(dist, query.distunit || 'decmiles') || '';
area = geo.gui.scaleWidget.formatUnit(area, query.areaunit || 'decmiles', geo.gui.scaleWidget.areaUnitsTable);
if (area) {
dist = (dist ? dist + ' - ' : '') + area;
}
if (present.includes(id)) {
$('#annotationlist .entry[annotation-id="' + id + '"] .entry-dist').text(dist);
return;
}
if (!annotation._changed) {
modifyAnnotation(annotation);
}
if (annotation.state() === geo.annotation.state.create) {
return;
}
var entry = $('#annotationlist .entry#sample').clone();
entry.attr({id: '', 'annotation-id': id});
entry.find('.entry-name').text(annotation.name());
entry.find('.entry-dist').text(dist);
$('#annotationlist').append(entry);
});
let dist = geo.gui.scaleWidget.formatUnit(totaldist || undefined, query.distunit || 'decmiles') || '';
const area = geo.gui.scaleWidget.formatUnit(totalarea || undefined, query.areaunit || 'decmiles', geo.gui.scaleWidget.areaUnitsTable);
if (area) {
dist = (dist ? dist + ' - ' : '') + area;
}
$('#annotationheader .entry-dist').text(dist);
$('#annotationheader').toggleClass('present', $('#annotationlist .entry').length > 1);
if (!fromGeojsonUpdate) {
Update the geojson textarea
var geojson = layer.geojson();
$('#geojson').val(geojson ? JSON.stringify(geojson, undefined, 2) : '');
if (query.save !== 'false') {
query.geojson = geojson ? JSON.stringify(geojson) : undefined;
utils.setQuery(query);
}
}
}
/**
* Handle selecting a control.
*
* @param evt jquery evt that triggered this call.
*/
function select_control(evt) {
var mode,
ctl = $(evt.target),
action = ctl.attr('action'),
id = ctl.closest('.entry').attr('annotation-id'),
annotation = layer.annotationById(id);
switch (action) {
case 'adjust':
layer.mode(layer.modes.edit, annotation);
layer.draw();
break;
case 'edit':
show_edit_dialog(id);
break;
case 'remove':
layer.removeAnnotation(annotation);
break;
case 'remove-all':
fromButtonSelect = true;
mode = layer.mode();
layer.mode(null);
layer.removeAllAnnotations();
layer.mode(mode);
fromButtonSelect = false;
break;
}
}
/**
* Show the edit dialog for a particular annotation.
*
* @param {number} id the annotation id to edit.
*/
function show_edit_dialog(id) {
var annotation = layer.annotationById(id),
type = annotation.type(),
typeMatch = new RegExp('(^| )(' + type + '|all)( |$)'),
opt = annotation.options(),
dlg = $('#editdialog');
$('#edit-validation-error', dlg).text('');
dlg.attr('annotation-id', id);
dlg.attr('annotation-type', type);
$('[option="name"]', dlg).val(annotation.name());
$('[option="label"]', dlg).val(annotation.label(undefined, true));
$('[option="description"]', dlg).val(annotation.description());
Populate each control with the current value of the annotation
$('.form-group[annotation-types]').each(function () {
var ctl = $(this),
key = $('[option]', ctl).attr('option'),
format = $('[option]', ctl).attr('format'),
value;
if (!ctl.attr('annotation-types').match(typeMatch)) {
If a property doesn’t exist for the current annotation’s type, hide the control
ctl.hide();
return;
}
ctl.show();
switch ($('[option]', ctl).attr('optiontype')) {
case 'option':
value = opt[key];
if (key === 'showLabel') {
value = '' + !!opt.showLabel;
}
break;
case 'label':
value = (opt.labelStyle || {})[key];
break;
default:
value = opt.style[key];
break;
}
switch (format) {
case 'angle':
if (value !== undefined && value !== null && value !== '') {
value = '' + +(+value * 180.0 / Math.PI).toFixed(4) + ' deg';
}
break;
case 'color':
always show colors as hex values
value = geo.util.convertColorToHex(value || {r: 0, g: 0, b: 0}, 'needed');
break;
case 'coordinate2':
if (value !== undefined && value !== null && value !== '') {
value = '' + value.x + ', ' + value.y;
}
}
if ((value === undefined || value === '' || value === null) && $('[option]', ctl).is('select')) {
value = $('[option] option', ctl).eq(0).val();
}
$('[option]', ctl).val(value === undefined ? '' : '' + value);
});
dlg.one('shown.bs.modal', function () {
$('[option="name"]', dlg).focus();
});
dlg.modal();
}
/**
* Update an annotation from values in the edit dialog.
*
* @param evt jquery evt that triggered this call.
*/
function edit_update(evt) {
evt.preventDefault();
var dlg = $('#editdialog'),
id = dlg.attr('annotation-id'),
annotation = layer.annotationById(id),
opt = annotation.options(),
type = annotation.type(),
typeMatch = new RegExp('(^| )(' + type + '|all)( |$)'),
newopt = {style: {}, labelStyle: {}},
error;
Validate form values
$('.form-group[annotation-types]').each(function () {
var ctl = $(this),
key = $('[option]', ctl).attr('option'),
format = $('[option]', ctl).attr('format'),
value, oldvalue;
if (!ctl.attr('annotation-types').match(typeMatch)) {
return;
}
value = $('[option]', ctl).val();
switch (format) {
case 'angle':
if (/^\s*[.0-9eE]+\s*$/.exec(value)) {
value += 'deg';
}
break;
}
switch (key) {
case 'textScaled':
if (['true', 'on', 'yes'].indexOf(value.trim().toLowerCase()) >= 0) {
value = map.zoom();
}
break;
}
value = layer.validateAttribute(value, format);
switch ($('[option]', ctl).attr('optiontype')) {
case 'option':
oldvalue = opt[key];
break;
case 'label':
oldvalue = (opt.labelStyle || {})[key];
break;
default:
oldvalue = opt.style[key];
break;
}
if (value === oldvalue || (oldvalue === undefined && value === '')) {
don’t change anything
} else if (value === undefined) {
error = $('label', ctl).text() + ' is not a valid value';
} else {
switch ($('[option]', ctl).attr('optiontype')) {
case 'option':
newopt[key] = value;
break;
case 'label':
newopt.labelStyle[key] = value;
break;
default:
newopt.style[key] = value;
break;
}
}
});
if (error) {
$('#edit-validation-error', dlg).text(error);
return;
}
annotation.name($('[option="name"]', dlg).val());
annotation.label($('[option="label"]', dlg).val() || null);
annotation.description($('[option="description"]', dlg).val() || '');
annotation.options(newopt).draw();
dlg.modal('hide');
Refresh the annotation list
handleAnnotationChange();
}