mapshaper
Version:
A tool for editing geospatial data for mapping and GIS.
1,725 lines (1,523 loc) • 768 kB
JavaScript
(function () {
var api = window.mapshaper; // assuming mapshaper is in global scope
var mapshaper = api,
utils$1 = api.utils,
cli = api.cli,
geom = api.geom,
internal = api.internal,
Bounds$1 = internal.Bounds,
UserError$1 = internal.UserError,
message$1 = internal.message, // stop, error and message are overridden in gui-proxy.js
stop$1 = internal.stop,
error$2 = internal.error;
api.enableLogging();
function CatalogControl(gui, catalog, onSelect) {
var self = this,
container = gui.container.findChild('.file-catalog'),
cols = catalog.cols,
enabled = true,
items = catalog.items,
n = items.length,
row = 0,
html, rows;
this.reset = function() {
enabled = true;
container.removeClass('downloading');
this.progress(-1);
};
this.progress = function() {}; // set by click handler
if (n > 0 === false) {
console.error("Catalog is missing array of items");
return;
}
gui.container.addClass('catalog-mode');
if (!cols) {
cols = Math.ceil(Math.sqrt(n));
}
rows = Math.ceil(n / cols);
html = '<table>';
if (catalog.title) {
html += utils$1.format('<tr><th colspan="%d"><h4>%s</h4></th></tr>', cols, catalog.title);
}
while (row < rows) {
html += renderRow(items.slice(row * cols, row * cols + cols));
row++;
}
html += '</table>';
container.node().innerHTML = html;
gui.container.findChildren('.file-catalog td').forEach(function(el, i) {
el.on('click', function() {
selectItem(el, i);
});
});
// Generate onprogress callback to show a progress indicator
function getProgressFunction(el) {
var visible = false,
i = 0;
return function(pct) {
i++;
if (i == 2 && pct < 0.5) {
// only show progress bar if file will take a while to load
visible = true;
}
if (pct == -1) {
// kludge to reset progress bar
el.removeClass('downloading');
pct = 0;
}
if (visible) {
el.css('background-size', (Math.round(pct * 100) + '% 100%'));
}
};
}
function renderRow(items) {
var tds = items.map(function(o, col) {
var i = row * cols + col;
return renderCell(o, i);
});
return '<tr>' + tds.join('') + '</tr>';
}
function selectItem(el,i) {
var pageUrl = window.location.href.toString().replace(/[?#].*/, '').replace(/\/$/, '') + '/';
var item = items[i];
var urls = item.files.map(function(file) {
var url = (item.url || '') + file;
if (/^http/.test(url) === false) {
// assume relative url
url = pageUrl + '/' + url;
}
return url;
});
if (enabled) { // only respond to first click
self.progress = getProgressFunction(el);
el.addClass('downloading');
container.addClass('downloading');
enabled = false;
onSelect(urls);
}
}
function renderCell(item, i) {
var template = '<td data-id="%d"><h4 class="title">%s</h4><div class="subtitle">%s</div></td>';
return utils$1.format(template, i, item.title, item.subtitle || '');
}
}
function Handler(type, target, callback, listener, priority) {
this.type = type;
this.callback = callback;
this.listener = listener || null;
this.priority = priority || 0;
this.target = target;
}
Handler.prototype.trigger = function(evt) {
if (!evt) {
evt = new EventData(this.type);
evt.target = this.target;
} else if (evt.target != this.target || evt.type != this.type) {
error$2("[Handler] event target/type have changed.");
}
this.callback.call(this.listener, evt);
};
function EventData(type, target, data) {
this.type = type;
this.target = target;
if (data) {
utils$1.defaults(this, data);
this.data = data;
}
}
EventData.prototype.stopPropagation = function() {
this.__stop__ = true;
};
// Base class for objects that dispatch events
function EventDispatcher() {}
// @obj (optional) data object, gets mixed into event
// @listener (optional) dispatch event only to this object
EventDispatcher.prototype.dispatchEvent = function(type, obj, listener) {
var evt;
// TODO: check for bugs if handlers are removed elsewhere while firing
var handlers = this._handlers;
if (handlers) {
for (var i = 0, len = handlers.length; i < len; i++) {
var handler = handlers[i];
if (handler.type == type && (!listener || listener == handler.listener)) {
if (!evt) {
evt = new EventData(type, this, obj);
}
else if (evt.__stop__) {
break;
}
handler.trigger(evt);
}
}
}
};
EventDispatcher.prototype.addEventListener =
EventDispatcher.prototype.on = function(type, callback, context, priority) {
context = context || this;
priority = priority || 0;
var handler = new Handler(type, this, callback, context, priority);
// Insert the new event in the array of handlers according to its priority.
var handlers = this._handlers || (this._handlers = []);
var i = handlers.length;
while (--i >= 0 && handlers[i].priority < handler.priority) {}
handlers.splice(i+1, 0, handler);
return this;
};
// Remove an event handler.
// @param {string} type Event type to match.
// @param {function(BoundEvent)} callback Event handler function to match.
// @param {*=} context Execution context of the event handler to match.
// @return {number} Returns number of handlers removed (expect 0 or 1).
EventDispatcher.prototype.removeEventListener = function(type, callback, context) {
context = context || this;
var count = this.removeEventListeners(type, callback, context);
return count;
};
// Remove event handlers; passing arguments can limit which listeners to remove
// Returns nmber of handlers removed.
EventDispatcher.prototype.removeEventListeners = function(type, callback, context) {
var handlers = this._handlers;
var newArr = [];
var count = 0;
for (var i = 0; handlers && i < handlers.length; i++) {
var evt = handlers[i];
if ((!type || type == evt.type) &&
(!callback || callback == evt.callback) &&
(!context || context == evt.listener)) {
count += 1;
}
else {
newArr.push(evt);
}
}
this._handlers = newArr;
return count;
};
EventDispatcher.prototype.countEventListeners = function(type) {
var handlers = this._handlers,
len = handlers && handlers.length || 0,
count = 0;
if (!type) return len;
for (var i = 0; i < len; i++) {
if (handlers[i].type === type) count++;
}
return count;
};
function getPageXY(el) {
var x = 0, y = 0;
if (el.getBoundingClientRect) {
var box = el.getBoundingClientRect();
x = box.left - pageXToViewportX(0);
y = box.top - pageYToViewportY(0);
}
else {
var fixed = elementIsFixed(el);
while (el) {
x += el.offsetLeft || 0;
y += el.offsetTop || 0;
el = el.offsetParent;
}
if (fixed) {
var offsX = -pageXToViewportX(0);
var offsY = -pageYToViewportY(0);
x += offsX;
y += offsY;
}
}
var obj = {x:x, y:y};
return obj;
}
function elementIsFixed(el) {
// get top-level offsetParent that isn't body (cf. Firefox)
var body = document.body;
var parent;
while (el && el != body) {
parent = el;
el = el.offsetParent;
}
// Look for position:fixed in the computed style of the top offsetParent.
// var styleObj = parent && (parent.currentStyle || window.getComputedStyle && window.getComputedStyle(parent, '')) || {};
var styleObj = parent && getElementStyle(parent) || {};
return styleObj.position == 'fixed';
}
function pageXToViewportX(x) {
return x - window.pageXOffset;
}
function pageYToViewportY(y) {
return y - window.pageYOffset;
}
function getElementStyle(el) {
return el.currentStyle || window.getComputedStyle && window.getComputedStyle(el, '') || {};
}
function getClassNameRxp(cname) {
return new RegExp("(^|\\s)" + cname + "(\\s|$)");
}
function hasClass(el, cname) {
var rxp = getClassNameRxp(cname);
return el && rxp.test(el.className);
}
function addClass(el, cname) {
var classes = el.className;
if (!classes) {
classes = cname;
}
else if (!hasClass(el, cname)) {
classes = classes + ' ' + cname;
}
el.className = classes;
}
function removeClass(el, cname) {
var rxp = getClassNameRxp(cname);
el.className = el.className.replace(rxp, "$2");
}
function replaceClass(el, c1, c2) {
var r1 = getClassNameRxp(c1);
el.className = el.className.replace(r1, '$1' + c2 + '$2');
}
var cssDiv = document.createElement('div');
function mergeCSS(s1, s2) {
cssDiv.style.cssText = s1 + ";" + s2; // extra ';' for ie, which may leave off final ';'
return cssDiv.style.cssText;
}
function addCSS(el, css) {
// console.error(css);
el.style.cssText = mergeCSS(el.style.cssText, css);
}
// Return: HTML node reference or null
// Receive: node reference or id or "#" + id
function getElement(ref) {
var el;
if (typeof ref == 'string') {
if (ref.charAt(0) == '#') {
ref = ref.substr(1);
}
if (ref == 'body') {
el = document.getElementsByTagName('body')[0];
}
else {
el = document.getElementById(ref);
}
}
else if (ref && ref.nodeType !== void 0) {
el = ref;
}
return el || null;
}
function undraggable(el) {
el.ondragstart = function(){return false;};
el.draggable = false;
}
function onload(handler) {
if (document.readyState == 'complete') {
handler();
} else {
window.addEventListener('load', handler);
}
}
async function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.head.appendChild(script);
});
}
var tagOrIdSelectorRE = /^#?[\w-]+$/;
El.__select = function(selector, root) {
root = root || document;
var els;
if (document.querySelectorAll) {
try {
els = root.querySelectorAll(selector);
} catch (e) {
error$2("Invalid selector:", selector);
}
} else {
error$2("This browser doesn't support CSS query selectors");
}
return utils$1.toArray(els);
};
// Converts dash-separated names (e.g. background-color) to camelCase (e.g. backgroundColor)
// Doesn't change names that are already camelCase
//
El.toCamelCase = function(str) {
var cc = str.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); });
return cc;
};
El.fromCamelCase = function(str) {
var dashed = str.replace(/([A-Z])/g, "-$1").toLowerCase();
return dashed;
};
El.setStyle = function(el, name, val) {
var jsName = El.toCamelCase(name);
if (el.style[jsName] == void 0) {
return;
}
var cssVal = val;
if (isFinite(val) && val !== null) {
cssVal = String(val); // problem if converted to scientific notation
if (jsName != 'opacity' && jsName != 'zIndex') {
cssVal += "px";
}
}
el.style[jsName] = cssVal;
};
El.findAll = function(sel, root) {
return El.__select(sel, root);
};
function El(ref) {
if (!ref) error$2("Element() needs a reference");
if (ref instanceof El) {
return ref;
}
else if (this instanceof El === false) {
return new El(ref);
}
var node;
if (utils$1.isString(ref)) {
if (ref[0] == '<') {
var parent = El('div').html(ref).node();
node = parent.childNodes.length == 1 ? parent.childNodes[0] : parent;
} else if (tagOrIdSelectorRE.test(ref)) {
node = getElement(ref) || document.createElement(ref); // TODO: detect type of argument
} else {
node = El.__select(ref)[0];
}
} else if (ref.tagName) {
node = ref;
}
if (!node) error$2("Unmatched element selector:", ref);
this.el = node;
}
utils$1.inherit(El, EventDispatcher);
utils$1.extend(El.prototype, {
clone: function() {
var el = this.el.cloneNode(true);
if (el.nodeName == 'SCRIPT') {
// Assume scripts are templates and convert to divs, so children
// can ...
el = El('div').addClass(el.className).html(el.innerHTML).node();
}
el.id = utils$1.getUniqueName();
this.el = el;
return this;
},
node: function() {
return this.el;
},
width: function() {
return this.el.offsetWidth;
},
height: function() {
return this.el.offsetHeight;
},
top: function() {
return this.el.offsetTop;
},
left: function() {
return this.el.offsetLeft;
},
// Apply inline css styles to this Element, either as string or object.
css: function(css, val) {
if (utils$1.isObject(css)) {
utils$1.forEachProperty(css, function(val, key) {
El.setStyle(this.el, key, val);
}, this);
} else if (val === void 0) {
addCSS(this.el, css);
} else {
El.setStyle(this.el, css, val);
}
return this;
},
attr: function(obj, value) {
if (utils$1.isString(obj)) {
if (arguments.length == 1) {
return this.el.getAttribute(obj);
}
if (value === null) {
this.el.removeAttribute(obj);
} else {
this.el.setAttribute(obj, value);
}
}
return this;
},
remove: function(sel) {
if (this.el.parentNode) this.el.parentNode.removeChild(this.el);
return this;
},
addClass: function(className) {
addClass(this.el, className);
return this;
},
removeClass: function(className) {
removeClass(this.el, className);
return this;
},
classed: function(className, b) {
this[b ? 'addClass' : 'removeClass'](className);
return this;
},
hasClass: function(className) {
return hasClass(this.el, className);
},
toggleClass: function(cname) {
if (this.hasClass(cname)) {
this.removeClass(cname);
} else {
this.addClass(cname);
}
},
computedStyle: function() {
return getElementStyle(this.el);
},
visible: function() {
if (this._hidden !== undefined) {
return !this._hidden;
}
var style = this.computedStyle();
return style.display != 'none' && style.visibility != 'hidden';
},
hide: function(css) {
if (this.visible()) {
this.css('display:none;');
this._hidden = true;
}
return this;
},
show: function(css) {
// var tag = this.el && this.el.tagName;
if (!this.visible()) {
// don't assume 'display:block'
this.el?.style.removeProperty('display');
if (this.computedStyle().display == 'none') {
this.css('display', 'block');
}
// this.css('display', tag == 'SPAN' ? 'inline-block' : 'block');
this._hidden = false;
}
return this;
},
html: function(html) {
if (arguments.length == 0) {
return this.el.innerHTML;
}
this.el.innerHTML = html;
return this;
},
text: function(str) {
if (arguments.length == 0) {
return this.el.innerText;
}
this.html(utils$1.htmlEscape(str));
return this;
},
// Shorthand for attr('id', <name>)
id: function(id) {
if (id) {
this.el.id = id;
return this;
}
return this.el.id;
},
findChild: function(sel) {
var node = El.__select(sel, this.el)[0];
return node ? new El(node) : null;
},
findChildren: function(sel) {
return El.__select(sel, this.el).map(El);
},
appendTo: function(ref) {
var parent = ref instanceof El ? ref.el : getElement(ref);
if (this._sibs) {
for (var i=0, len=this._sibs.length; i<len; i++) {
parent.appendChild(this._sibs[i]);
}
}
parent.appendChild(this.el);
return this;
},
nextSibling: function() {
return this.el.nextSibling ? new El(this.el.nextSibling) : null;
},
firstChild: function() {
var ch = this.el.firstChild;
while (ch.nodeType != 1) { // skip text nodes
ch = ch.nextSibling;
}
return new El(ch);
},
appendChild: function(ref) {
var el = El(ref);
this.el.appendChild(el.el);
return this;
},
newChild: function(tagName) {
var ch = document.createElement(tagName);
this.el.appendChild(ch);
return new El(ch);
},
// Traverse to parent node
parent: function() {
var p = this.el && this.el.parentNode;
return p ? new El(p) : null;
},
findParent: function(tagName) {
var p = this.el && this.el.parentNode;
if (tagName) {
tagName = tagName.toUpperCase();
while (p && p.tagName != tagName) {
p = p.parentNode;
}
}
return p ? new El(p) : null;
},
// Remove all children of this element
empty: function() {
this.el.innerHTML = '';
return this;
}
});
// use DOM handler for certain events
// TODO: find a better way distinguising DOM events and other events registered on El
// e.g. different methods
//
//El.prototype.__domevents = utils.arrayToIndex("click,mousedown,mousemove,mouseup".split(','));
El.prototype.__on = El.prototype.on;
El.prototype.on = function(type, func) {
if (this.constructor == El) {
this.el.addEventListener(type, func);
} else {
this.__on.apply(this, arguments);
}
return this;
};
El.prototype.__removeEventListener = El.prototype.removeEventListener;
El.prototype.removeEventListener = function(type, func) {
if (this.constructor == El) {
this.el.removeEventListener(type, func);
} else {
this.__removeEventListener.apply(this, arguments);
}
return this;
};
var GUI = {};
GUI.isActiveInstance = function(gui) {
return gui == GUI.__active;
};
GUI.getPixelRatio = function() {
var deviceRatio = window.devicePixelRatio || window.webkitDevicePixelRatio || 1;
return deviceRatio > 1 ? 2 : 1;
};
GUI.browserIsSupported = function() {
return typeof ArrayBuffer != 'undefined' &&
typeof Blob != 'undefined' && typeof File != 'undefined';
};
GUI.exportIsSupported = function() {
return typeof URL != 'undefined' && URL.createObjectURL &&
typeof document.createElement("a").download != "undefined" ||
!!window.navigator.msSaveBlob;
};
// TODO: make this relative to a single GUI instance
GUI.canSaveToServer = function() {
return !!(mapshaper.manifest && mapshaper.manifest.allow_saving) && typeof fetch == 'function';
};
GUI.setSavedValue = function(name, val) {
try {
window.localStorage.setItem(name, JSON.stringify(val));
} catch(e) {}
};
GUI.getSavedValue = function(name) {
try {
return JSON.parse(window.localStorage.getItem(name));
} catch(e) {}
return null;
};
GUI.getUrlVars = function() {
var q = window.location.search.substring(1);
return q.split('&').reduce(function(memo, chunk) {
var pair = chunk.split('=');
var key = decodeURIComponent(pair[0]);
memo[key] = parseVal(pair[1]);
return memo;
}, {});
function parseVal(val) {
var str = val ? decodeURIComponent(val) : 'true';
if (str == 'true' || str == 'false') return JSON.parse(str);
return str;
}
};
// Assumes that URL path ends with a filename
GUI.getUrlFilename = function(url) {
var path = /\/\/([^#?]+)/.exec(url);
var file = path ? path[1].split('/').pop() : '';
return file;
};
GUI.formatMessageArgs = function(args) {
// .replace(/^\[[^\]]+\] ?/, ''); // remove cli annotation (if present)
return internal.formatLogArgs(args);
};
GUI.handleDirectEvent = function(cb) {
return function(e) {
if (e.target == this) cb();
};
};
GUI.getInputElement = function() {
var el = document.activeElement;
return (el && (el.tagName == 'INPUT' || el.contentEditable == 'true')) ? el : null;
};
GUI.textIsSelected = function() {
return !!GUI.getInputElement();
};
GUI.selectElement = function(el) {
var range = document.createRange(),
sel = window.getSelection();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
};
GUI.blurActiveElement = function() {
var el = GUI.getInputElement();
if (el) el.blur();
};
// Filter out delayed click events, e.g. so users can highlight and copy text
// Filter out context menu clicks
GUI.onClick = function(el, cb) {
var time;
el.on('mousedown', function() {
time = +new Date();
});
el.on('mouseup', function(e) {
if (looksLikeContextClick(e)) {
return;
}
if (+new Date() - time < 300) {
cb(e);
}
});
};
GUI.onContextClick = function(el, cb) {
el.on('mouseup', function(e) {
if (looksLikeContextClick(e)) {
e.stopPropagation();
e.preventDefault();
cb(e);
}
});
};
function looksLikeContextClick(e) {
return e.button > 1 || e.ctrlKey;
}
// tests if filename is a type that can be used
// GUI.isReadableFileType = function(filename) {
// return !!internal.guessInputFileType(filename) || internal.couldBeDsvFile(filename) ||
// internal.isZipFile(filename);
// };
GUI.parseFreeformOptions = function(raw, cmd) {
var str = raw.trim(),
parsed;
if (!str) {
return {};
}
if (!/^-/.test(str)) {
str = '-' + cmd + ' ' + str;
}
parsed = internal.parseCommands(str);
if (!parsed.length || parsed[0].name != cmd) {
stop$1("Unable to parse command line options");
}
return parsed[0].options;
};
// Convert an options object to a command line options string
// (used by gui-import-control.js)
// TODO: handle options with irregular string <-> object conversion
GUI.formatCommandOptions = function(o) {
var arr = [];
Object.keys(o).forEach(function(key) {
var name = key.replace(/_/g, '-');
var val = o[key];
var str;
// TODO: quote values that contain spaces
if (Array.isArray(val)) {
str = name + '=' + val.join(',');
} else if (val === true) {
str = name;
} else if (val === false) {
return;
} else {
str = name + '=' + val;
}
arr.push(str);
});
return arr.join(' ');
};
// TODO: switch all ClickText to ClickText2
// @ref Reference to an element containing a text node
function ClickText2(ref) {
var self = this;
var selected = false;
var el = El(ref).on('mousedown', init);
function init() {
el.removeEventListener('mousedown', init);
el.attr('contentEditable', true)
.attr('spellcheck', false)
.attr('autocorrect', false)
.on('focus', function(e) {
el.addClass('editing');
selected = false;
}).on('blur', function(e) {
el.removeClass('editing');
self.dispatchEvent('change');
window.getSelection().removeAllRanges();
}).on('keydown', function(e) {
if (e.keyCode == 13) { // enter
e.stopPropagation();
e.preventDefault();
this.blur();
}
}).on('click', function(e) {
if (!selected && window.getSelection().isCollapsed) {
GUI.selectElement(el.node());
}
selected = true;
e.stopPropagation();
});
}
this.value = function(str) {
if (utils$1.isString(str)) {
el.node().textContent = str;
} else {
return el.node().textContent;
}
};
}
utils$1.inherit(ClickText2, EventDispatcher);
// @ref reference to a text input element
function ClickText(ref) {
var _el = El(ref);
var _self = this;
var _max = Infinity,
_min = -Infinity,
_formatter = function(v) {return String(v);},
_validator = function(v) {return !isNaN(v);},
_parser = function(s) {return parseFloat(s);},
_value = 0;
_el.on('blur', onblur);
_el.on('keydown', onpress);
function onpress(e) {
if (e.keyCode == 27) { // esc
_self.value(_value); // reset input field to current value
_el.el.blur();
} else if (e.keyCode == 13) { // enter
_el.el.blur();
}
}
// Validate input contents.
// Update internal value and fire 'change' if valid
//
function onblur() {
var val = _parser(_el.el.value);
if (val === _value) {
// return;
}
if (_validator(val)) {
_self.value(val);
_self.dispatchEvent('change', {value:_self.value()});
} else {
_self.value(_value);
_self.dispatchEvent('error'); // TODO: improve
}
}
this.bounds = function(min, max) {
_min = min;
_max = max;
return this;
};
this.validator = function(f) {
_validator = f;
return this;
};
this.formatter = function(f) {
_formatter = f;
return this;
};
this.parser = function(f) {
_parser = f;
return this;
};
this.text = function() {return _el.el.value;};
this.value = function(arg) {
if (arg == void 0) {
// var valStr = this.el.value;
// return _parser ? _parser(valStr) : parseFloat(valStr);
return _value;
}
var val = utils$1.clamp(arg, _min, _max);
if (!_validator(val)) {
error$2("ClickText#value() invalid value:", arg);
} else {
_value = val;
}
_el.el.value = _formatter(val);
return this;
};
}
utils$1.inherit(ClickText, EventDispatcher);
function Checkbox(ref) {
var _el = El(ref);
}
utils$1.inherit(Checkbox, EventDispatcher);
function SimpleButton(ref) {
var _el = El(ref),
_active = !_el.hasClass('disabled');
_el.active = function(a) {
if (a === void 0) return _active;
if (a !== _active) {
_active = a;
_el.toggleClass('disabled');
}
return _el;
};
// this.node = function() {return _el.node();};
function isVisible() {
var el = _el.node();
return el.offsetParent !== null;
}
return _el;
}
function filterLayerByIds(lyr, ids) {
var shapes;
if (lyr.shapes) {
shapes = ids.map(function(id) {
return lyr.shapes[id];
});
return utils$1.defaults({shapes: shapes, data: null}, lyr);
}
return lyr;
}
function formatLayerNameForDisplay(name) {
return name || '[unnamed]';
}
function cleanLayerName(raw) {
return raw.replace(/[\n\t/\\]/g, '')
.replace(/^[.\s]+/, '').replace(/[.\s]+$/, '');
}
function updateLayerStackOrder(layers) {
// 1. assign ascending ids to unassigned layers above the range of other layers
layers.forEach(function(o, i) {
if (!o.layer.menu_order) o.layer.menu_order = 1e6 + i;
});
// 2. sort in ascending order
layers.sort(function(a, b) {
return a.layer.menu_order - b.layer.menu_order;
});
// 3. assign consecutve ids
layers.forEach(function(o, i) {
o.layer.menu_order = i + 1;
});
return layers;
}
function sortLayersForMenuDisplay(layers) {
layers = updateLayerStackOrder(layers);
return layers.reverse();
}
function setLayerPinning(lyr, pinned) {
lyr.pinned = !!pinned;
}
function calcDotScale(layers, ext) {
var bbox = ext.getBounds().scale(1.3).toArray(); // add buffer
// var topTier = 50000; // can be a bottleneck
var topTier = 10000; // short-circuit counting here
var count = 0;
layers = layers.filter(function(lyr) {
return lyr.geometry_type == 'point' && lyr.gui.style?.dotSize > 0;
});
layers.forEach(function(lyr) {
count += countPoints(lyr.gui.displayLayer.shapes, topTier, bbox);
});
count = Math.min(topTier, count) || 1;
var k = Math.pow(5 - utils$1.clamp(Math.log10(count), 1, 4), 1.25);
// zoom adjustments
var mapScale = ext.scale();
if (mapScale < 0.5) {
k *= Math.pow(mapScale + 0.5, 0.35);
} else if (mapScale > 1) {
// scale faster at first
k *= Math.pow(Math.min(mapScale, 4), 0.25);
k *= Math.pow(mapScale, 0.02);
}
// scale down when map is small
var smallSide = Math.min(ext.width(), ext.height());
k *= utils$1.clamp(smallSide / 500, 0.5, 1);
return k;
}
function countPoints(shapes, max, bbox) {
var count = 0;
var shp, p;
// short-circuit point counting above top threshold
for (var i=0, n=shapes.length; i<n && count<max; i++) {
shp = shapes[i];
for (var j=0, m=(shp ? shp.length : 0); j<m; j++) {
p = shp[j];
if (p[0] > bbox[0] && p[0] < bbox[2] && p[1] > bbox[1] && p[1] < bbox[3]) {
count ++;
}
}
}
return count;
}
async function showPrompt(msg, title) {
var popup = showPopupAlert(msg, title);
return new Promise(function(resolve) {
popup.onCancel(function() {
resolve(false);
});
popup.button('Yes', function() {
resolve(true);
});
popup.button('No', function() {
resolve(false);
});
});
}
function showPopupAlert(msg, title, optsArg) {
var opts = optsArg || {};
var self = {}, html = '';
var _cancel, _close;
var warningRxp = /^Warning: /;
var el = El('div').appendTo('body').addClass('alert-wrapper')
.classed('non-blocking', opts.non_blocking);
var infoBox = El('div').appendTo(el).addClass('alert-box info-box selectable');
El('div').appendTo(infoBox).addClass('close2-btn').on('click', function() {
if (_cancel) _cancel();
self.close();
});
if (opts.max_width) {
infoBox.node().style.maxWidth = opts.max_width;
}
var container = El('div').appendTo(infoBox);
if (!title && warningRxp.test(msg)) {
title = 'Warning';
msg = msg.replace(warningRxp, '');
}
if (title) {
El('div').addClass('alert-title').text(title).appendTo(container);
}
var content = El('div').appendTo(infoBox);
if (msg) {
content.html(`<p class="alert-message">${msg}</p>`);
}
self.container = function() { return content; };
self.onCancel = function(cb) {
_cancel = cb;
return self;
};
self.onClose = function(cb) {
_close = cb;
return self;
};
self.button = function(label, cb) {
El('div')
.addClass("btn dialog-btn alert-btn")
.appendTo(infoBox)
.html(label)
.on('click', function() {
self.close();
cb();
});
return self;
};
self.close = function(action) {
var ms = 0;
var _el = el;
if (action == 'fade' && _el) {
ms = 1000;
_el.addClass('fade-out');
}
if (_close) _close();
el = _cancel = _close = null;
setTimeout(function() {
if (_el) _el.remove();
}, ms);
};
return self;
}
function AlertControl(gui) {
var openAlert; // error popup
var openPopup; // any popup
var quiet = false;
gui.addMode('alert', function() {}, closePopup);
gui.alert = function(str, title) {
closePopup();
openAlert = openPopup = showPopupAlert(str, title);
openAlert.onClose(gui.clearMode);
gui.enterMode('alert');
};
gui.quiet = function(flag) {
quiet = !!flag;
};
gui.message = function(str, title) {
if (quiet) return;
if (openPopup) return; // don't stomp on another popup
openPopup = showPopupAlert(str, title);
openPopup.onClose(function() {openPopup = null;});
};
function closePopup() {
if (openPopup) openPopup.close();
openPopup = openAlert = null;
}
}
function saveZipFile(zipfileName, files, done) {
internal.zipAsync(files, function(err, buf) {
if (err) {
done(errorMessage(err));
} else {
saveBlobToLocalFile(zipfileName, new Blob([buf]), done);
}
});
function errorMessage(err) {
var str = "Error creating Zip file";
if (err.message) {
str += ": " + err.message;
}
return str;
}
}
function saveFilesToServer(paths, data, done) {
var i = -1;
next();
function next(err) {
i++;
if (err) return done(err);
if (i >= data.length) return done();
saveBlobToServer(paths[i], new Blob([data[i]]), next);
}
}
function saveBlobToServer(path, blob, done) {
var q = '?file=' + encodeURIComponent(path);
var url = window.location.origin + '/save' + q;
window.fetch(url, {
method: 'POST',
credentials: 'include',
body: blob
}).then(function(resp) {
if (resp.status == 400) {
return resp.text();
}
}).then(function(err) {
done(err);
}).catch(function(resp) {
done('connection to server was lost');
});
}
// save file to selected folder if supported, else to downloads
function saveBlobToLocalFile2(filename, blob) {
if (window.showSaveFilePicker) {
saveBlobToSelectedFile(filename, blob);
} else {
saveBlobToDownloadsFolder(filename, blob);
}
}
async function saveBlobToLocalFile(filename, blob, done) {
var chooseDir = GUI.getSavedValue('choose-save-dir');
done = done || function() {};
if (chooseDir) {
saveBlobToSelectedFile(filename, blob, done);
} else {
saveBlobToDownloadsFolder(filename, blob, done);
}
}
function showSaveDialog(filename, blob, done) {
var alert = showPopupAlert(`Save ${filename} to:`)
.button('selected folder', function() {
saveBlobToSelectedFile(filename, blob, done);
})
.button('downloads', function() {
saveBlobToDownloadsFolder(filename, blob, done);
})
.onCancel(done);
}
async function saveBlobToSelectedFile(filename, blob, done) {
// see: https://developer.chrome.com/articles/file-system-access/
// note: saving multiple files to a directory using showDirectoryPicker()
// does not work well (in Chrome). User is prompted for permission each time,
// and some directories (like Downloads and Documents) are blocked.
//
var options = getSaveFileOptions(filename);
var handle;
done = done || function() {};
try {
handle = await window.showSaveFilePicker(options);
var writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch(e) {
if (e.name == 'SecurityError') {
// assuming this is a timeout error, with message like:
// "Must be handling a user gesture to show a file picker."
showSaveDialog(filename, blob, done);
} else if (e.name == 'AbortError') {
// fired if user clicks a cancel button (normal, no error message)
// BUT: this kind of erro rmay also get fired when saving to a protected folder
// (should show error message)
done();
} else {
console.error(e.name, e.message, e);
done('Save failed for an unknown reason');
}
return;
}
done();
}
function getSaveFileOptions(filename) {
// see: https://wicg.github.io/file-system-access/#api-filepickeroptions
var type = internal.guessInputFileType(filename);
var ext = internal.getFileExtension(filename).toLowerCase();
var accept = {};
if (ext == 'kml') {
accept['application/vnd.google-earth.kml+xml'] = ['.kml'];
} else if (ext == 'svg') {
accept['image/svg+xml'] = ['.svg'];
} else if (ext == 'zip') {
accept['application/zip'] == ['.zip'];
} else if (type == 'text') {
accept['text/csv'] = ['.csv', '.tsv', '.tab', '.txt'];
} else if (type == 'json') {
accept['application/json'] = ['.json', '.geojson', '.topojson'];
} else {
accept['application/octet-stream'] = ['.' + ext];
}
return {
suggestedName: filename,
// If startIn is given, Chrome will always start there
// Default is to start in the previously selected dir (better)
// // startIn: 'downloads', // or: desktop, documents, [file handle], [directory handle]
types: [{
description: 'Files',
accept: accept
}]
};
}
function saveBlobToDownloadsFolder(filename, blob, done) {
var anchor, blobUrl;
done = done || function() {};
try {
blobUrl = URL.createObjectURL(blob);
} catch(e) {
done("Mapshaper can't export files from this browser. Try switching to Chrome or Firefox.");
return;
}
anchor = El('a').attr('href', '#').appendTo('body').node();
anchor.href = blobUrl;
anchor.download = filename;
var clickEvent = document.createEvent("MouseEvent");
clickEvent.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0, false,
false, false, false, 0, null);
anchor.dispatchEvent(clickEvent);
setTimeout(function() {
// Revoke blob url to release memory; timeout needed in firefox
URL.revokeObjectURL(blobUrl);
anchor.parentNode.removeChild(anchor);
done();
}, 400);
}
function logStartupCleanup(opts) {
opts = opts || {};
var count = opts.count || 0;
if (!count || typeof console == 'undefined' || !console.log) return;
var sessionCount = opts.sessionCount || 0;
var singular = opts.singular || 'item';
var plural = opts.plural || singular + 's';
var itemLabel = count == 1 ? singular : plural;
var msg = '[mapshaper] startup cleanup reclaimed ' + count + ' ' + itemLabel;
if (sessionCount > 0) {
msg += ' from ' + sessionCount + ' stale session' + (sessionCount == 1 ? '' : 's');
}
if (opts.sizeBytes > 0) {
msg += ' (' + formatCleanupSize(opts.sizeBytes) + ')';
}
console.log(msg);
}
function formatCleanupSize(bytes) {
var kb = Math.round(bytes / 1000);
var mb = (bytes / 1e6).toFixed(1);
if (!kb) return '';
if (kb < 990) return kb + 'kB';
return mb + 'MB';
}
// Several dependencies are loaded via require()
var f;
if (typeof require == 'function') {
// Node.js context: native require() function
f = require;
} else if (typeof window == 'object' && window.modules) {
// running in web GUI
f = function(name) {
return window.modules[name];
};
} else {
// stub to avoid runtime error in a handful of tests
f = function() {};
}
var require$1 = f;
var idb$2 = require$1('idb-keyval');
// https://github.com/jakearchibald/idb
// https://github.com/jakearchibald/idb-keyval
var sessionId$1 = getUniqId$1('session');
var snapshotCount = 0;
// IDs of snapshots created (and not removed) by this tab. Tracked in memory
// so the pagehide handler can fire a single batched delMany() without first
// awaiting idb.keys() -- the page may not survive the round trip.
var ownSnapshotIds = new Set();
// Lifecycle constants for snapshot cleanup.
// HEARTBEAT_INTERVAL_MS: how often this tab refreshes its localStorage entry.
// STALE_THRESHOLD_MS: a session whose heartbeat is older than this is treated
// as dead. Generous enough to tolerate backgrounded/throttled tabs.
// BROADCAST_DISCOVERY_MS: how long startup waits for live tabs to identify
// themselves over BroadcastChannel before deciding what to delete.
var HEARTBEAT_INTERVAL_MS$1 = 30 * 1000;
var STALE_THRESHOLD_MS$1 = 5 * 60 * 1000;
var BROADCAST_DISCOVERY_MS = 200;
var SESSION_DATA_KEY = 'session_data';
var BROADCAST_CHANNEL_NAME = 'mapshaper-snapshots';
function getUniqId$1(prefix) {
return prefix + '_' + (Math.random() + 1).toString(36).substring(2,8);
}
function getSessionFromSnapshotId(snapshotId) {
// Snapshot ids look like 'session_<6chars>_<NNN>'. The session id is the
// 'session_<6chars>' prefix.
var m = /^(session_[a-z0-9]+)_\d+$/.exec(snapshotId);
return m ? m[1] : null;
}
function isSnapshotId(str) {
return getSessionFromSnapshotId(str) !== null;
}
function SessionSnapshots(gui) {
gui.sessionSnapshots = {
enabled: false,
saveSnapshot: function() {
return saveSnapshot(gui);
},
restoreLatestSnapshot: function() {
return restoreLatestSnapshot(gui);
},
renderSnapshotList: function(el) {
return renderSnapshotList(el, gui);
}
};
init();
async function init() {
var enabled = await isStorageEnabled();
if (!enabled) {
return;
}
gui.sessionSnapshots.enabled = true;
startLifecycle();
await initialCleanup();
// 'pagehide' is more reliable than 'unload' across modern browsers
// (Chrome's bfcache rules increasingly suppress 'unload'). Sync work
// (localStorage write, BroadcastChannel notice) always completes; the
// best-effort delMany() below frequently completes in Chrome/Firefox and
// sometimes in Safari, but is not relied upon -- the next session's
// startup cleanup is the safety net.
window.addEventListener('pagehide', function(e) {
if (e.persisted) return; // bfcache: tab may come back, leave entry alive
removeOwnSession();
announceLeaving();
attemptOwnDataDeletion();
});
}
async function saveSnapshot(gui) {
var obj = await captureSnapshot(gui);
if (!obj) return;
// storing an unpacked object is usually a bit faster (~20%)
// note: we don't know the size of unpacked snapshot objects
// obj = internal.pack(obj);
var entryId = String(++snapshotCount).padStart(3, '0');
var snapshotId = sessionId$1 + '_' + entryId; // e.g. session_d89fw_001
var size = obj.length;
var entry = {
created: Date.now(),
session: sessionId$1,
id: snapshotId,
name: snapshotCount + '.',
number: snapshotCount,
size: size,
display_size: formatCleanupSize(size)
};
await idb$2.set(entry.id, obj);
ownSnapshotIds.add(entry.id);
await addToIndex(entry);
}
}
async function renderSnapshotList(el, gui) {
var snapshots = await fetchSnapshotList();
el.empty();
snapshots.forEach(function(item) {
var line = El('div').appendTo(el).addClass('save-menu-item');
El('span').appendTo(line).html(`<span class="save-item-label">#${item.number}</span> `);
El('span').appendTo(line).html(` <span class="save-item-size">${item.display_size}</span>`);
El('span').addClass('save-menu-btn').appendTo(line).on('click', async function(e) {
e.stopPropagation();
await restoreSnapshotById(item.id, gui);
}).text('restore');
El('span').addClass('save-menu-btn').appendTo(line).on('click', async function(e) {
var obj;
e.stopPropagation();
obj = await idb$2.get(item.id);
await internal.compressSnapshotForExport(obj);
var buf = internal.pack(obj);
var fileName = `snapshot-${String(item.number).padStart(2, '0')}.msx`;
saveBlobToLocalFile2(fileName, new Blob([buf]));
}).text('export');
El('span').addClass('save-menu-btn').appendTo(line).on('click', async function(e) {
e.stopPropagation();
await removeSnapshotById(item.id);
renderSnapshotList(el, gui);
}).text('remove');
});
}
async function fetchSnapshotList() {
await pruneIndexAgainstKeys();
var index = await fetchIndex();
var snapshots = index.snapshots;
snapshots = snapshots.filter(function(o) {return o.session == sessionId$1;});
return snapshots.sort(function(a, b) {b.created > a.created;});
}
async function removeSnapshotById(id, gui) {
await idb$2.del(id);
ownSnapshotIds.delete(id);
return updateIndex(function(index) {
index.snapshots = index.snapshots.filter(function(snap) {
return snap.id != id;
});
});
}
async function restoreSnapshotById(id, gui) {
var data;
try {
data = await internal.restoreSessionData(await idb$2.get(id));
} catch(e) {
console.error(e);
stop$1('Snapshot is not available');
}
gui.model.clear();
importDatasets(data.datasets, gui);
// Reinstate the session history (including its saved/unsaved boundary) that
// was in effect when the snapshot was taken. If the snapshot has no history
// field (e.g. older snapshots), this resets to a clean state.
gui.session.restoreHistorySnapshot(data.history);
if (gui.undo) gui.undo.clear();
gui.clearMode();
}
async function restoreLatestSnapshot(gui) {
var snapshots = await fetchSnapshotList();
var latest = snapshots.reduce(function(memo, item) {
return !memo || item.created > memo.created ? item : memo;
}, null);
if (!latest) return false;
await restoreSnapshotById(latest.id, gui);
return true;
}
// Import datasets from a packed .msx buffer.
// Behavior depends on whether the current session contains data:
// - empty session: full project restore -- datasets and any embedded session
// history are loaded as if continuing the original session.
// - non-empty session: merge -- datasets are added to the current project,
// but any embedded session history is discarded (the imported commands
// assume different layer indices and a different starting state, so
// merging them into the current session would produce a misleading history).
// Returns true if a full restore occurred, false if a merge occurred. The
// caller uses this to decide whether to record an additional -i command in
// the current session's history (see gui-import-control.mjs).
// TODO: figure out if interface data should be imported (e.g. should
// visibility flag of imported layers be imported)
async function importSessionData(buf, gui) {
if (buf instanceof ArrayBuffer) {
buf = new Uint8Array(buf);
}
var data = await internal.unpackSessionData(buf);
var fullRestore = gui.model.isEmpty();
importDatasets(data.datasets, gui);
if (fullRestore) {
gui.session.restoreHistorySnapshot(data.history);
}
return fullRestore;
}
function importDatasets(datasets, gui) {
regenerateRasterPreviews(datasets);
gui.model.addDatasets(datasets);
var target = findTargetLayer(datasets);
delete target.layers[0].active; // kludge, active flag only used in snapshots now
gui.model.setDefaultTarget(target.layers, target.dataset);
gui.model.updated({select: true, arc_count: true}); // arc_count to refresh display shapes
}
function regenerateRasterPreviews(datasets) {
datasets.forEach(function(dataset) {
dataset.layers.forEach(function(lyr) {
var raster = lyr.raster;
if (!raster || !raster.grid || raster.view && raster.view.preview) return;
raster.view = raster.view || {};
raster.view.preview = internal.createRasterPreview(raster);
});
});
}
async function captureSnapshot(gui) {
var lyr = gui.model.getActiveLayer()?.layer;
if (!lyr) return null; // no data -- no snapshot
// compact: true applies compression to vector coordinates, for ~30% reduction
// in file size in a typical polygon or polyline file, but longer processing time
// history: capture session commands + saved/unsaved boundary so the history
// can be reinstated if this snapshot is restored or re-imported later.
var opts = {
compact: false,
active_layer: lyr,
history: gui.session.getHistorySnapshot()
};
var datasets = gui.model.getDatasets();
var obj = await internal.exportDatasetsToPack(datasets, opts);
obj.gui = getGuiState(gui);
return obj;
}
// TODO: capture gui state information to allow restoring more of the UI
function getGuiState(gui) {
return null;
}
async function fetchIndex() {
var index = await idb$2.get('msx_index');
return index || {snapshots: []};
}
async function updateIndex(action) {
return idb$2.update('msx_index', function(index) {
if (!index || !Array.isArray(index.snapshots)) {
index = {snapshots: []};
}
action(index);
return index;
});
}
async function addToIndex(obj) {
touchOwnSession();
return updateIndex(function(index) {
index.snapshots.push(obj);
});
}
// Drop index entries whose underlying IndexedDB blob has gone missing
// (e.g. cleared by another tab). Cheaper than reclaimDeadSessionData; used
// before rendering the menu to keep stale entries out of the UI.
async function pruneIndexAgainstKeys() {
var keys = await idb$2.keys();
return updateIndex(function(index) {
index.snapshots = index.snapshots.filter(function(snap) {
return keys.includes(snap.id);
});
});
}
// Run on every fresh page load. Aggressively reclaim space by deleting any
// snapshot whose owning session is no longer alive.
async function initialCleanup() {
touchOwnSession();
pruneStaleSessionData();
var liveSessions = await dis