UNPKG

mapshaper

Version:

A tool for editing geospatial data for mapping and GIS.

1,725 lines (1,523 loc) 768 kB
(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