').text(msg).appendTo(gui.progressMessage.empty().show());
+ };
+
+ gui.clearProgressMessage = function() {
+ if (gui.progressMessage) gui.progressMessage.hide();
+ };
+
+ gui.consoleIsOpen = function() {
+ return gui.container.hasClass('console-open');
+ };
+
+ // Make this instance interactive and editable
+ gui.focus = function() {
+ var curr = GUI.__active;
+ if (curr == gui) return;
+ if (curr) {
+ curr.blur();
+ }
+ GUI.__active = gui;
+ MessageProxy(gui);
+ ImportFileProxy(gui);
+ WriteFilesProxy(gui);
+ gui.dispatchEvent('active');
+ };
+
+ gui.blur = function() {
+ if (GUI.isActiveInstance(gui)) {
+ GUI.__active = null;
+ gui.dispatchEvent('inactive');
+ }
+ };
+
+ // switch between multiple gui instances on mouse click
+ gui.container.node().addEventListener('mouseup', function(e) {
+ if (GUI.isActiveInstance(gui)) return;
+ e.stopPropagation();
+ gui.focus();
+ }, true); // use capture
+
+ if (opts.focus) {
+ gui.focus();
+ }
+
+ return gui;
+}
diff --git a/src/gui/mapshaper-gui-lib.js b/src/gui/mapshaper-gui-lib.js
index 89672a317..132e7c45a 100644
--- a/src/gui/mapshaper-gui-lib.js
+++ b/src/gui/mapshaper-gui-lib.js
@@ -1,18 +1,25 @@
/* @requires
mapshaper-gui-start
mbloch-gui-lib
-mapshaper-gui-modes
*/
-var gui = api.gui = new ModeSwitcher();
-api.enableLogging();
-
-gui.browserIsSupported = function() {
+GUI.browserIsSupported = function() {
return typeof ArrayBuffer != 'undefined' &&
typeof Blob != 'undefined' && typeof File != 'undefined';
};
-gui.getUrlVars = function() {
+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.getUrlVars = function() {
var q = window.location.search.substring(1);
return q.split('&').reduce(function(memo, chunk) {
var pair = chunk.split('=');
@@ -23,29 +30,29 @@ gui.getUrlVars = function() {
};
// Assumes that URL path ends with a filename
-gui.getUrlFilename = function(url) {
+GUI.getUrlFilename = function(url) {
var path = /\/\/([^#?]+)/.exec(url);
var file = path ? path[1].split('/').pop() : '';
return file;
};
-gui.formatMessageArgs = function(args) {
- // remove cli annotation (if present)
- return internal.formatLogArgs(args).replace(/^\[[^\]]+\] ?/, '');
+GUI.formatMessageArgs = function(args) {
+ // .replace(/^\[[^\]]+\] ?/, ''); // remove cli annotation (if present)
+ return internal.formatLogArgs(args);
};
-gui.handleDirectEvent = function(cb) {
+GUI.handleDirectEvent = function(cb) {
return function(e) {
if (e.target == this) cb();
};
};
-gui.getInputElement = function() {
+GUI.getInputElement = function() {
var el = document.activeElement;
return (el && (el.tagName == 'INPUT' || el.contentEditable == 'true')) ? el : null;
};
-gui.selectElement = function(el) {
+GUI.selectElement = function(el) {
var range = document.createRange(),
sel = getSelection();
range.selectNodeContents(el);
@@ -53,13 +60,13 @@ gui.selectElement = function(el) {
sel.addRange(range);
};
-gui.blurActiveElement = function() {
- var el = gui.getInputElement();
+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
-gui.onClick = function(el, cb) {
+GUI.onClick = function(el, cb) {
var time;
el.on('mousedown', function() {
time = +new Date();
@@ -68,3 +75,26 @@ gui.onClick = function(el, cb) {
if (+new Date() - time < 300) cb(e);
});
};
+
+// tests if filename is a type that can be used
+GUI.isReadableFileType = function(filename) {
+ var ext = utils.getFileExtension(filename).toLowerCase();
+ 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("Unable to parse command line options");
+ }
+ return parsed[0].options;
+};
diff --git a/src/gui/mapshaper-gui-model.js b/src/gui/mapshaper-gui-model.js
index 361c46778..d828d49d0 100644
--- a/src/gui/mapshaper-gui-model.js
+++ b/src/gui/mapshaper-gui-model.js
@@ -2,15 +2,38 @@
function Model() {
var self = new api.internal.Catalog();
+ var deleteLayer = self.deleteLayer;
utils.extend(self, EventDispatcher.prototype);
+ // override Catalog method (so -drop command will work in web console)
+ self.deleteLayer = function(lyr, dataset) {
+ var active, flags;
+ deleteLayer.call(self, lyr, dataset);
+ if (self.isEmpty()) {
+ // refresh browser if deleted layer was the last layer
+ window.location.href = window.location.href.toString();
+ } else {
+ // trigger event to update layer list and, if needed, the map view
+ flags = {};
+ active = self.getActiveLayer();
+ if (active.layer != lyr) {
+ flags.select = true;
+ }
+ internal.cleanupArcs(active.dataset);
+ if (internal.layerHasPaths(lyr)) {
+ flags.arc_count = true; // looks like a kludge, try to remove
+ }
+ self.updated(flags, active.layer, active.dataset);
+ }
+ };
+
self.updated = function(flags, lyr, dataset) {
var targ, active;
// if (lyr && dataset && (!active || active.layer != lyr)) {
if (lyr && dataset) {
self.setDefaultTarget([lyr], dataset);
}
- targ = self.getDefaultTarget();
+ targ = self.getDefaultTargets()[0];
if (lyr && targ.layers[0] != lyr) {
flags.select = true;
}
diff --git a/src/gui/mapshaper-gui-modes.js b/src/gui/mapshaper-gui-modes.js
index 2d1d5875f..759223dbd 100644
--- a/src/gui/mapshaper-gui-modes.js
+++ b/src/gui/mapshaper-gui-modes.js
@@ -1,4 +1,4 @@
-/* @requires mapshaper-gui-lib */
+/* @requires mapshaper-gui-lib, mapshaper-mode-button */
function ModeSwitcher() {
var self = this;
@@ -9,7 +9,7 @@ function ModeSwitcher() {
};
// return a function to trigger this mode
- self.addMode = function(name, enter, exit) {
+ self.addMode = function(name, enter, exit, btn) {
self.on('mode', function(e) {
if (e.prev == name) {
exit();
@@ -18,6 +18,9 @@ function ModeSwitcher() {
enter();
}
});
+ if (btn) {
+ new ModeButton(self, btn, name);
+ }
};
self.addMode(null, function() {}, function() {}); // null mode
diff --git a/src/gui/mapshaper-gui-options.js b/src/gui/mapshaper-gui-options.js
deleted file mode 100644
index c1e73c10f..000000000
--- a/src/gui/mapshaper-gui-options.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/* @requires mapshaper-gui-lib */
-
-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("Unable to parse command line options");
- }
- return parsed[0].options;
-};
diff --git a/src/gui/mapshaper-gui-proxy.js b/src/gui/mapshaper-gui-proxy.js
index 130c39ea0..8f4c4a0e8 100644
--- a/src/gui/mapshaper-gui-proxy.js
+++ b/src/gui/mapshaper-gui-proxy.js
@@ -3,13 +3,61 @@
// These functions could be called when validating i/o options; TODO: avoid this
cli.isFile =
cli.isDirectory = function(name) {return false;};
-
cli.validateOutputDir = function() {};
+function MessageProxy(gui) {
+ // Replace error function in mapshaper lib
+ error = internal.error = function() {
+ stop.apply(null, utils.toArray(arguments));
+ };
+
+ // replace stop function
+ stop = internal.stop = function() {
+ // Show a popup error message, then throw an error
+ var msg = GUI.formatMessageArgs(arguments);
+ gui.alert(msg);
+ throw new Error(msg);
+ };
+
+ message = internal.message = function() {
+ internal.logArgs(arguments); // reset default
+ };
+}
+
+function WriteFilesProxy(gui) {
+ // replaces function from mapshaper.js
+ internal.writeFiles = function(files, opts, done) {
+ var filename;
+ if (!utils.isArray(files) || files.length === 0) {
+ done("Nothing to export");
+ } else if (GUI.canSaveToServer() && !opts.save_to_download_folder) {
+ saveFilesToServer(files, opts, function(err) {
+ var msg;
+ if (err) {
+ msg = "
Direct save failedReason: " + err + ".";
+ msg += "
Saving to download folder instead.";
+ gui.alert(msg);
+ // fall back to standard method if saving to server fails
+ internal.writeFiles(files, {save_to_download_folder: true}, done);
+ } else {
+ done();
+ }
+ });
+ } else if (files.length == 1) {
+ saveBlobToDownloadFolder(files[0].filename, new Blob([files[0].content]), done);
+ } else {
+ filename = utils.getCommonFileBase(utils.pluck(files, 'filename')) || "output";
+ saveZipFile(filename + ".zip", files, done);
+ }
+ };
+}
+
// Replaces functions for reading from files with functions that try to match
// already-loaded datasets.
//
-function ImportFileProxy(model) {
+function ImportFileProxy(gui) {
+ var model = gui.model;
+
// Try to match an imported dataset or layer.
// TODO: think about handling import options
function find(src) {
@@ -36,11 +84,43 @@ function ImportFileProxy(model) {
layers: dataset.layers.map(internal.copyLayer)
}, dataset);
};
+}
- /*
- api.importDataTable = function(src, opts) {
- var dataset = find(src);
- return dataset.layers[0].data;
- };
- */
+// load Proj.4 CRS definition files dynamically
+//
+internal.initProjLibrary = function(opts, done) {
+ var mproj = require('mproj');
+ var libs = internal.findProjLibs([opts.from || '', opts.match || '', opts.crs || ''].join(' '));
+ // skip loaded libs
+ libs = libs.filter(function(name) {return !mproj.internal.mproj_search_libcache(name);});
+ loadProjLibs(libs, done);
+};
+
+function loadProjLibs(libs, done) {
+ var mproj = require('mproj');
+ var i = 0;
+ next();
+
+ function next() {
+ var libName = libs[i];
+ var content, req;
+ if (!libName) return done();
+ req = new XMLHttpRequest();
+ req.addEventListener('load', function(e) {
+ if (req.status == 200) {
+ content = req.response;
+ }
+ });
+ req.addEventListener('loadend', function() {
+ if (content) {
+ mproj.internal.mproj_insert_libcache(libName, content);
+ }
+ // TODO: consider stopping with an error message if no content was loaded
+ // (currently, a less specific error will occur when mapshaper tries to use the library)
+ next();
+ });
+ req.open('GET', 'assets/' + libName);
+ req.send();
+ i++;
+ }
}
diff --git a/src/gui/mapshaper-gui-export.js b/src/gui/mapshaper-gui-save.js
similarity index 56%
rename from src/gui/mapshaper-gui-export.js
rename to src/gui/mapshaper-gui-save.js
index 70221dd0d..c1094dae6 100644
--- a/src/gui/mapshaper-gui-export.js
+++ b/src/gui/mapshaper-gui-save.js
@@ -1,24 +1,5 @@
/* @requires mapshaper-gui-lib */
-gui.exportIsSupported = function() {
- return typeof URL != 'undefined' && URL.createObjectURL &&
- typeof document.createElement("a").download != "undefined" ||
- !!window.navigator.msSaveBlob;
-};
-
-// replaces function from mapshaper.js
-internal.writeFiles = function(files, opts, done) {
- var filename;
- if (!utils.isArray(files) || files.length === 0) {
- done("Nothing to export");
- } else if (files.length == 1) {
- saveBlob(files[0].filename, new Blob([files[0].content]), done);
- } else {
- filename = utils.getCommonFileBase(utils.pluck(files, 'filename')) || "output";
- saveZipFile(filename + ".zip", files, done);
- }
-};
-
function saveZipFile(zipfileName, files, done) {
var toAdd = files;
var zipWriter;
@@ -31,10 +12,15 @@ function saveZipFile(zipfileName, files, done) {
done("This browser doesn't support Zip file creation.");
}
- function zipError(msg) {
+ function zipError(err) {
var str = "Error creating Zip file";
+ var msg = '';
+ // error events thrown by Zip library seem to be missing a message
+ if (err && err.message) {
+ msg = err.message;
+ }
if (msg) {
- str += ": " + (msg.message || msg);
+ str += ": " + msg;
}
done(str);
}
@@ -42,7 +28,7 @@ function saveZipFile(zipfileName, files, done) {
function nextFile() {
if (toAdd.length === 0) {
zipWriter.close(function(blob) {
- saveBlob(zipfileName, blob, done);
+ saveBlobToDownloadFolder(zipfileName, blob, done);
});
} else {
var obj = toAdd.pop(),
@@ -52,7 +38,41 @@ function saveZipFile(zipfileName, files, done) {
}
}
-function saveBlob(filename, blob, done) {
+function saveFilesToServer(exports, opts, done) {
+ var paths = internal.getOutputPaths(utils.pluck(exports, 'filename'), opts);
+ var data = utils.pluck(exports, 'content');
+ var i = -1;
+ next();
+ function next(err) {
+ i++;
+ if (err) return done(err);
+ if (i >= exports.length) {
+ gui.alert('
Saved' + paths.join('
'));
+ 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;
+ 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');
+ });
+}
+
+function saveBlobToDownloadFolder(filename, blob, done) {
var anchor, blobUrl;
if (window.navigator.msSaveBlob) {
window.navigator.msSaveBlob(blob, filename);
diff --git a/src/gui/mapshaper-gui-shapes.js b/src/gui/mapshaper-gui-shapes.js
index 4086d051f..5df569438 100644
--- a/src/gui/mapshaper-gui-shapes.js
+++ b/src/gui/mapshaper-gui-shapes.js
@@ -1,79 +1,42 @@
/* @requires mapshaper-gui-lib */
-// A wrapper for ArcCollection that filters paths to speed up rendering.
-//
+// Create low-detail versions of large arc collections for faster rendering
+// at zoomed-out scales.
function FilteredArcCollection(unfilteredArcs) {
- var sortedThresholds,
- filteredArcs,
- filteredSegLen;
+ var size = unfilteredArcs.getPointCount(),
+ filteredArcs, filteredSegLen;
- init();
-
- function init() {
- var size = unfilteredArcs.getPointCount(),
- cutoff = 5e5,
- nth;
- sortedThresholds = filteredArcs = null;
+ // Only generate low-detail arcs for larger datasets
+ if (size > 5e5) {
if (!!unfilteredArcs.getVertexData().zz) {
- // If we have simplification data...
- // Sort simplification thresholds for all non-endpoint vertices
- // for quick conversion of simplification percentage to threshold value.
- // For large datasets, use every nth point, for faster sorting.
- nth = Math.ceil(size / cutoff);
- sortedThresholds = unfilteredArcs.getRemovableThresholds(nth);
- utils.quicksort(sortedThresholds, false);
- // For large datasets, create a filtered copy of the data for faster rendering
- if (size > cutoff) {
- filteredArcs = initFilteredArcs(unfilteredArcs, sortedThresholds);
- filteredSegLen = internal.getAvgSegment(filteredArcs);
- }
+ // Use precalculated simplification data for vertex filtering, if available
+ filteredArcs = initFilteredArcs(unfilteredArcs);
+ filteredSegLen = internal.getAvgSegment(filteredArcs);
} else {
- if (size > cutoff) {
- // generate filtered arcs when no simplification data is present
- filteredSegLen = internal.getAvgSegment(unfilteredArcs) * 4;
- filteredArcs = internal.simplifyArcsFast(unfilteredArcs, filteredSegLen);
- }
+ // Use fast simplification as a fallback
+ filteredSegLen = internal.getAvgSegment(unfilteredArcs) * 4;
+ filteredArcs = internal.simplifyArcsFast(unfilteredArcs, filteredSegLen);
}
}
- // Use simplification data to create a low-detail copy of arcs, for faster
- // rendering when zoomed-out.
- function initFilteredArcs(arcs, sortedThresholds) {
+ function initFilteredArcs(arcs) {
var filterPct = 0.08;
+ var nth = Math.ceil(arcs.getPointCount() / 5e5);
var currInterval = arcs.getRetainedInterval();
- var filterZ = sortedThresholds[Math.floor(filterPct * sortedThresholds.length)];
+ var filterZ = arcs.getThresholdByPct(filterPct, nth);
var filteredArcs = arcs.setRetainedInterval(filterZ).getFilteredCopy();
arcs.setRetainedInterval(currInterval); // reset current simplification
return filteredArcs;
}
this.getArcCollection = function(ext) {
- refreshFilteredArcs();
- // Use a filtered version of arcs at small scales
- var unitsPerPixel = 1/ext.getTransform().mx,
- useFiltering = filteredArcs && unitsPerPixel > filteredSegLen * 1.5;
- return useFiltering ? filteredArcs : unfilteredArcs;
- };
-
- function refreshFilteredArcs() {
if (filteredArcs) {
- if (filteredArcs.size() != unfilteredArcs.size()) {
- init();
- }
+ // match simplification of unfiltered arcs
filteredArcs.setRetainedInterval(unfilteredArcs.getRetainedInterval());
}
- }
-
- this.size = function() {return unfilteredArcs.size();};
-
- this.setRetainedPct = function(pct) {
- if (sortedThresholds) {
- var z = sortedThresholds[Math.floor(pct * sortedThresholds.length)];
- z = internal.clampIntervalByPct(z, pct);
- // this.setRetainedInterval(z);
- unfilteredArcs.setRetainedInterval(z);
- } else {
- unfilteredArcs.setRetainedPct(pct);
- }
+ // switch to filtered version of arcs at small scales
+ var unitsPerPixel = 1/ext.getTransform().mx,
+ useFiltering = filteredArcs && unitsPerPixel > filteredSegLen * 1.5;
+ return useFiltering ? filteredArcs : unfilteredArcs;
};
}
diff --git a/src/gui/mapshaper-gui-start.js b/src/gui/mapshaper-gui-start.js
index d8ae78bc0..8f8840c38 100644
--- a/src/gui/mapshaper-gui-start.js
+++ b/src/gui/mapshaper-gui-start.js
@@ -1,9 +1,14 @@
+var GUI = {}; // shared namespace for all GUI instances
var api = mapshaper; // assuming mapshaper is in global scope
var utils = api.utils;
var cli = api.cli;
var geom = api.geom;
var internal = api.internal;
-var Bounds = api.internal.Bounds;
-var APIError = api.internal.APIError;
-var message = api.internal.message;
+var Bounds = internal.Bounds;
+var UserError = internal.UserError;
+var message = internal.message;
+var stop = internal.stop; // stop and error are replaced in mapshaper-gui-proxy.js
+var error = internal.error;
+api.gui = true; // let the main library know we're running in the GUI
+api.enableLogging();
\ No newline at end of file
diff --git a/src/gui/mapshaper-gui-table.js b/src/gui/mapshaper-gui-table.js
index ae34bada8..9c6ec4e19 100644
--- a/src/gui/mapshaper-gui-table.js
+++ b/src/gui/mapshaper-gui-table.js
@@ -1,20 +1,17 @@
/* @requires mapshaper-gui-lib */
-gui.getDisplayLayerForTable = function(table) {
+function getDisplayLayerForTable(table) {
var n = table.size(),
cellWidth = 12,
cellHeight = 5,
gutter = 6,
arcs = [],
shapes = [],
- lyr = {shapes: shapes},
- data = {layer: lyr},
aspectRatio = 1.1,
- usePoints = false,
x, y, col, row, blockSize;
if (n > 10000) {
- usePoints = true;
+ arcs = null;
gutter = 0;
cellWidth = 4;
cellHeight = 4;
@@ -40,25 +37,24 @@ gui.getDisplayLayerForTable = function(table) {
col = Math.floor(i / blockSize);
x = col * (cellWidth + gutter);
y = cellHeight * (blockSize - row);
- if (usePoints) {
- shapes.push([[x, y]]);
- } else {
+ if (arcs) {
arcs.push(getArc(x, y, cellWidth, cellHeight));
shapes.push([[i]]);
+ } else {
+ shapes.push([[x, y]]);
}
}
- if (usePoints) {
- lyr.geometry_type = 'point';
- } else {
- data.arcs = new internal.ArcCollection(arcs);
- lyr.geometry_type = 'polygon';
- }
- lyr.data = table;
-
function getArc(x, y, w, h) {
return [[x, y], [x + w, y], [x + w, y - h], [x, y - h], [x, y]];
}
- return data;
-};
+ return {
+ layer: {
+ geometry_type: arcs ? 'polygon' : 'point',
+ shapes: shapes,
+ data: table
+ },
+ arcs: arcs ? new internal.ArcCollection(arcs) : null
+ };
+}
diff --git a/src/gui/mapshaper-gui.js b/src/gui/mapshaper-gui.js
index 6eb55046d..9cd37427d 100644
--- a/src/gui/mapshaper-gui.js
+++ b/src/gui/mapshaper-gui.js
@@ -1,25 +1,21 @@
/* @requires
mapshaper-gui-lib
+mapshaper-gui-instance
mapshaper-gui-error
mapshaper-simplify-control
mapshaper-import-control
mapshaper-export-control
mapshaper-repair-control
mapshaper-layer-control
-mapshaper-gui-proxy
-mapshaper-map
-mapshaper-maplayer
mapshaper-console
-mapshaper-gui-model
-mapshaper-gui-modes
*/
Browser.onload(function() {
- if (!gui.browserIsSupported()) {
+ if (!GUI.browserIsSupported()) {
El("#mshp-not-supported").show();
return;
}
- gui.startEditing();
+ startEditing();
if (window.location.hostname == 'localhost') {
window.addEventListener('beforeunload', function() {
// send termination signal for mapshaper-gui
@@ -30,45 +26,52 @@ Browser.onload(function() {
}
});
-gui.getImportOpts = function() {
- var vars = gui.getUrlVars();
- var urlFiles = vars.files ? vars.files.split(',') : [];
- var manifestFiles = mapshaper.manifest || [];
- return {
- files: urlFiles.concat(manifestFiles)
- };
-};
+function getImportOpts() {
+ var vars = GUI.getUrlVars();
+ var manifest = mapshaper.manifest || {};
+ var opts = {};
+ if (Array.isArray(manifest)) {
+ // old-style manifest: an array of filenames
+ opts.files = manifest;
+ } else if (manifest.files) {
+ opts.files = manifest.files.concat();
+ } else {
+ opts.files = [];
+ }
+ if (vars.files) {
+ opts.files = opts.files.concat(vars.files.split(','));
+ }
+ if (manifest.catalog) {
+ opts.catalog = manifest.catalog;
+ }
+ opts.display_all = !!manifest.display_all;
+ return opts;
+}
+
+var startEditing = function() {
+ var dataLoaded = false,
+ importOpts = getImportOpts(),
+ gui = new GuiInstance('body');
-gui.startEditing = function() {
- var model = new Model(),
- dataLoaded = false,
- map, repair, simplify;
- gui.startEditing = function() {};
- map = new MshpMap(model);
- repair = new RepairControl(model, map);
- simplify = new SimplifyControl(model);
- new AlertControl();
- new ImportFileProxy(model);
- new ImportControl(model, gui.getImportOpts());
- new ExportControl(model);
- new LayerControl(model, map);
+ new AlertControl(gui);
+ new RepairControl(gui);
+ new SimplifyControl(gui);
+ new ImportControl(gui, importOpts);
+ new ExportControl(gui);
+ new LayerControl(gui);
+ new Console(gui);
- model.on('select', function() {
+ startEditing = function() {};
+
+ gui.model.on('select', function() {
if (!dataLoaded) {
dataLoaded = true;
El('#mode-buttons').show();
- El('#nav-buttons').show();
- new Console(model);
+ if (importOpts.display_all) {
+ gui.model.getLayers().forEach(function(o) {
+ gui.map.setLayerVisibility(o, true);
+ });
+ }
}
});
- // TODO: untangle dependencies between SimplifyControl, RepairControl and Map
- simplify.on('simplify-start', function() {
- repair.hide();
- });
- simplify.on('simplify-end', function() {
- repair.update();
- });
- simplify.on('change', function(e) {
- map.setSimplifyPct(e.value);
- });
};
diff --git a/src/gui/mapshaper-hit-control.js b/src/gui/mapshaper-hit-control.js
deleted file mode 100644
index e1ab641fe..000000000
--- a/src/gui/mapshaper-hit-control.js
+++ /dev/null
@@ -1,187 +0,0 @@
-/* @requires mapshaper-gui-lib */
-
-function HitControl(ext, mouse) {
- var self = new EventDispatcher();
- var prevHits = [];
- var active = false;
- var tests = {
- polygon: polygonTest,
- polyline: polylineTest,
- point: pointTest
- };
- var coords = El('#coordinate-info').hide();
- var lyr, target, test;
-
- ext.on('change', function() {
- // shapes may change along with map scale
- target = lyr ? lyr.getDisplayLayer() : null;
- });
-
- self.setLayer = function(o) {
- lyr = o;
- target = o.getDisplayLayer();
- test = tests[target.layer.geometry_type];
- coords.hide();
- };
-
- self.start = function() {
- active = true;
- };
-
- self.stop = function() {
- if (active) {
- hover([]);
- coords.text('').hide();
- active = false;
- }
- };
-
- mouse.on('click', function(e) {
- if (!active || !target) return;
- trigger('click', prevHits);
- gui.selectElement(coords.node());
- });
-
- // DISABLING: This causes problems when hovering over the info panel
- // Deselect hover shape when pointer leaves hover area
- //mouse.on('leave', function(e) {
- // hover(-1);
- //});
-
- mouse.on('hover', function(e) {
- var p, decimals;
- if (!active || !target) return;
- p = ext.getTransform().invert().transform(e.x, e.y);
- if (target.geographic) {
- // update coordinate readout if displaying geographic shapes
- decimals = getCoordPrecision(ext.getBounds());
- coords.text(p[0].toFixed(decimals) + ', ' + p[1].toFixed(decimals)).show();
- }
- if (test && e.hover) {
- hover(test(p[0], p[1]));
- }
- });
-
- // Convert pixel distance to distance in coordinate units.
- function getHitBuffer(pix) {
- var dist = pix / ext.getTransform().mx,
- scale = ext.scale();
- if (scale < 1) dist *= scale; // reduce hit threshold when zoomed out
- return dist;
- }
-
- function getCoordPrecision(bounds) {
- var min = Math.min(Math.abs(bounds.xmax), Math.abs(bounds.ymax)),
- decimals = Math.ceil(Math.log(min) / Math.LN10);
- return Math.max(0, 7 - decimals);
- }
-
- function polygonTest(x, y) {
- var dist = getHitBuffer(5),
- cands = findHitCandidates(x, y, dist),
- hits = [],
- cand, hitId;
- for (var i=0; i
0 && hits.length === 0) {
- // secondary detection: proximity, if not inside a polygon
- hits = findNearestCandidates(x, y, dist, cands, target.dataset.arcs);
- }
- return hits;
- }
-
- function polylineTest(x, y) {
- var dist = getHitBuffer(15),
- cands = findHitCandidates(x, y, dist);
- return findNearestCandidates(x, y, dist, cands, target.dataset.arcs);
- }
-
- function findNearestCandidates(x, y, dist, cands, arcs) {
- var hits = [],
- cand, candDist;
- for (var i=0; i 0 ? hits[0] : -1
- });
- }
-
- function hover(hits) {
- if (!sameIds(hits, prevHits)) {
- prevHits = hits;
- El('#map-layers').classed('hover', hits.length > 0);
- trigger('hover', hits);
- }
- }
-
- function findHitCandidates(x, y, dist) {
- var arcs = target.dataset.arcs,
- index = {},
- cands = [],
- bbox = [];
- target.layer.shapes.forEach(function(shp, shpId) {
- var cand;
- for (var i = 0, n = shp && shp.length; i < n; i++) {
- arcs.getSimpleShapeBounds2(shp[i], bbox);
- if (x + dist < bbox[0] || x - dist > bbox[2] ||
- y + dist < bbox[1] || y - dist > bbox[3]) {
- continue; // bbox non-intersection
- }
- cand = index[shpId];
- if (!cand) {
- cand = index[shpId] = {shape: [], id: shpId};
- cands.push(cand);
- }
- cand.shape.push(shp[i]);
- }
- });
- return cands;
- }
-
- return self;
-}
diff --git a/src/gui/mapshaper-hit-control2.js b/src/gui/mapshaper-hit-control2.js
new file mode 100644
index 000000000..13fc42b76
--- /dev/null
+++ b/src/gui/mapshaper-hit-control2.js
@@ -0,0 +1,226 @@
+/*
+@requires mapshaper-shape-hit, mapshaper-svg-hit
+*/
+
+function HitControl2(gui, ext, mouse) {
+ var self = new EventDispatcher();
+ var storedData = noHitData(); // may include additional data from SVG symbol hit (e.g. hit node)
+ var active = false;
+ var shapeTest;
+ var svgTest;
+ var targetLayer;
+ // event priority is higher than navigation, so stopping propagation disables
+ // pan navigation
+ var priority = 2;
+
+ self.setLayer = function(mapLayer) {
+ if (!mapLayer || !internal.layerHasGeometry(mapLayer.layer)) {
+ shapeTest = null;
+ svgTest = null;
+ self.stop();
+ } else {
+ shapeTest = getShapeHitTest(mapLayer, ext);
+ svgTest = getSvgHitTest(mapLayer);
+ }
+ targetLayer = mapLayer;
+ // deselect any selection
+ // TODO: maintain selection if layer & shapes have not changed
+ updateHitData(null);
+ };
+
+ self.start = function() {
+ active = true;
+ };
+
+ self.stop = function() {
+ if (active) {
+ updateHitData(null); // no hit data, no event
+ active = false;
+ }
+ };
+
+ self.getHitId = function() {return storedData.id;};
+
+ // Get a reference to the active layer, so listeners to hit events can interact
+ // with data and shapes
+ self.getHitTarget = function() {
+ return targetLayer;
+ };
+
+ self.getTargetDataTable = function() {
+ var targ = self.getHitTarget();
+ return targ && targ.layer.data || null;
+ };
+
+ self.getSwitchHandler = function(diff) {
+ return function() {
+ self.switchSelection(diff);
+ };
+ };
+
+ self.switchSelection = function(diff) {
+ var i = storedData.ids.indexOf(storedData.id);
+ var n = storedData.ids.length;
+ if (i < 0 || n < 2) return;
+ if (diff != 1 && diff != -1) {
+ diff = 1;
+ }
+ storedData.id = storedData.ids[(i + diff + n) % n];
+ triggerHitEvent('change');
+ };
+
+ // make sure popup is unpinned and turned off when switching editing modes
+ // (some modes do not support pinning)
+ gui.on('interaction_mode_change', function(e) {
+ updateHitData(null);
+ if (e.mode == 'off') {
+ self.stop();
+ } else {
+ self.start();
+ }
+ });
+
+ mouse.on('dblclick', handlePointerEvent, null, priority);
+ mouse.on('dragstart', handlePointerEvent, null, priority);
+ mouse.on('drag', handlePointerEvent, null, priority);
+ mouse.on('dragend', handlePointerEvent, null, priority);
+
+ mouse.on('click', function(e) {
+ var hitData;
+ if (!shapeTest || !active) return;
+ e.stopPropagation();
+
+ // TODO: move pinning to inspection control?
+ if (gui.interaction.modeUsesClick(gui.interaction.getMode())) {
+ hitData = hitTest(e);
+ // TOGGLE pinned state under some conditions
+ if (!hitData.pinned && hitData.id > -1) {
+ hitData.pinned = true;
+ } else if (hitData.pinned && hitData.id == storedData.id) {
+ hitData.pinned = false;
+ }
+ updateHitData(hitData);
+ }
+
+ triggerHitEvent('click', e.data);
+
+ }, null, priority);
+
+ // Hits are re-detected on 'hover' (if hit detection is active)
+ mouse.on('hover', function(e) {
+ if (storedData.pinned || !shapeTest || !active) return;
+ if (!isOverMap(e)) {
+ // mouse is off of map viewport -- clear any current hit
+ updateHitData(null);
+ } else if (e.hover) {
+ // mouse is hovering directly over map area -- update hit detection
+ updateHitData(hitTest(e));
+ } else {
+ // mouse is over map viewport but not directly over map (e.g. hovering
+ // over popup) -- don't update hit detection
+ }
+
+ }, null, priority);
+
+ function noHitData() {return {ids: [], id: -1, pinned: false};}
+
+ function hitTest(e) {
+ var p = ext.translatePixelCoords(e.x, e.y);
+ var shapeHitIds = shapeTest(p[0], p[1]);
+ var svgData = svgTest(e); // null or a data object
+ var data = noHitData();
+ if (svgData) { // mouse is over an SVG symbol
+ utils.extend(data, svgData);
+ if (shapeHitIds) {
+ // if both SVG hit and shape hit, merge hit ids
+ data.ids = utils.uniq(data.ids.concat(shapeHitIds));
+ }
+ } else if (shapeHitIds) {
+ data.ids = shapeHitIds;
+ }
+
+ // update selected id
+ if (data.id > -1) {
+ // svg hit takes precedence over any prior hit
+ } else if (storedData.id > -1 && data.ids.indexOf(storedData.id) > -1) {
+ data.id = storedData.id;
+ } else if (data.ids.length > 0) {
+ data.id = data.ids[0];
+ }
+
+ // update pinned property
+ if (storedData.pinned && data.id > -1) {
+ data.pinned = true;
+ }
+ return data;
+ }
+
+ // If hit ids have changed, update stored hit ids and fire 'hover' event
+ // evt: (optional) mouse event
+ function updateHitData(newData) {
+ if (!newData) {
+ newData = noHitData();
+ }
+ if (!testHitChange(storedData, newData)) {
+ return;
+ }
+ storedData = newData;
+ gui.container.findChild('.map-layers').classed('symbol-hit', newData.ids.length > 0);
+ if (active) {
+ triggerHitEvent('change');
+ }
+ }
+
+ // check if an event is used in the current interaction mode
+ function eventIsEnabled(type) {
+ var mode = gui.interaction.getMode();
+ if (type == 'click' && !gui.interaction.modeUsesClick(mode)) {
+ return false;
+ }
+ if ((type == 'drag' || type == 'dragstart' || type == 'dragend') && !gui.interaction.modeUsesDrag(mode)) {
+ return false;
+ }
+ return true;
+ }
+
+ function isOverMap(e) {
+ return e.x >= 0 && e.y >= 0 && e.x < ext.width() && e.y < ext.height();
+ }
+
+ function handlePointerEvent(e) {
+ if (!shapeTest || !active) return;
+ if (self.getHitId() == -1) return; // ignore pointer events when no features are being hit
+ // don't block pan and other navigation in modes when they are not being used
+ if (eventIsEnabled(e.type)) {
+ e.stopPropagation(); // block navigation
+ triggerHitEvent(e.type, e.data);
+ }
+ }
+
+ // d: event data (may be a pointer event object, an ordinary object or null)
+ function triggerHitEvent(type, d) {
+ // Merge stored hit data into the event data
+ var eventData = utils.extend({}, d || {}, storedData);
+ self.dispatchEvent(type, eventData);
+ }
+
+ // Test if two hit data objects are equivalent
+ function testHitChange(a, b) {
+ // check change in 'container', e.g. so moving from anchor hit to label hit
+ // is detected
+ if (sameIds(a.ids, b.ids) && a.container == b.container && a.pinned == b.pinned && a.id == b.id) {
+ return false;
+ }
+ return true;
+ }
+
+ function sameIds(a, b) {
+ if (a.length != b.length) return false;
+ for (var i=0; i)
-function DropControl(cb) {
- var el = El('body');
- el.on('dragleave', ondrag);
- el.on('dragover', ondrag);
- el.on('drop', ondrop);
- function ondrag(e) {
+function DropControl(el, cb) {
+ var area = El(el);
+ area.on('dragleave', ondragleave)
+ .on('dragover', ondragover)
+ .on('drop', ondrop);
+ function ondragleave(e) {
+ block(e);
+ out();
+ }
+ function ondragover(e) {
// blocking drag events enables drop event
- e.preventDefault();
+ block(e);
+ over();
}
function ondrop(e) {
- e.preventDefault();
+ block(e);
+ out();
cb(e.dataTransfer.files);
}
+ function over() {
+ area.addClass('dragover');
+ }
+ function out() {
+ area.removeClass('dragover');
+ }
+ function block(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
}
// @el DOM element for select button
@@ -54,177 +62,234 @@ function FileChooser(el, cb) {
}
}
-function ImportControl(model, opts) {
- new SimpleButton('#import-buttons .submit-btn').on('click', submitFiles);
- new SimpleButton('#import-buttons .cancel-btn').on('click', gui.clearMode);
+function ImportControl(gui, opts) {
+ var model = gui.model;
var importCount = 0;
var queuedFiles = [];
var manifestFiles = opts.files || [];
- gui.addMode('import', turnOn, turnOff);
- new DropControl(receiveFiles);
+ var _importOpts = {};
+ var cachedFiles = {};
+ var catalog;
+
+ if (opts.catalog) {
+ catalog = new CatalogControl(gui, opts.catalog, downloadFiles);
+ }
+
+ new SimpleButton('#import-buttons .submit-btn').on('click', onSubmit);
+ new SimpleButton('#import-buttons .cancel-btn').on('click', gui.clearMode);
+ new DropControl('body', receiveFiles); // default drop area is entire page
+ new DropControl('#import-drop', receiveFiles);
+ new DropControl('#import-quick-drop', receiveFilesQuickView);
new FileChooser('#file-selection-btn', receiveFiles);
new FileChooser('#import-buttons .add-btn', receiveFiles);
new FileChooser('#add-file-btn', receiveFiles);
+
+ gui.keyboard.onMenuSubmit(El('#import-options'), onSubmit);
+
+ gui.addMode('import', turnOn, turnOff);
gui.enterMode('import');
+
gui.on('mode', function(e) {
// re-open import opts if leaving alert or console modes and nothing has been imported yet
- if (!e.name && importCount === 0) {
+ if (!e.name && model.isEmpty()) {
gui.enterMode('import');
}
});
function findMatchingShp(filename) {
- var base = utils.getPathBase(filename);
+ // use case-insensitive matching
+ var base = utils.getPathBase(filename).toLowerCase();
return model.getDatasets().filter(function(d) {
- var fname = d.info.input_files[0] || '';
+ var fname = d.info.input_files && d.info.input_files[0] || "";
var ext = utils.getFileExtension(fname).toLowerCase();
- var base2 = utils.getPathBase(fname);
+ var base2 = utils.getPathBase(fname).toLowerCase();
return base == base2 && ext == 'shp';
});
}
function turnOn() {
- var el = El('#import-options');
if (manifestFiles.length > 0) {
- downloadFiles(manifestFiles);
+ downloadFiles(manifestFiles, true);
manifestFiles = [];
- } else {
- if (importCount > 0) {
- el.removeClass('first-run');
- }
- el.show();
+ } else if (model.isEmpty()) {
+ gui.container.addClass('splash-screen');
}
}
function turnOff() {
+ var target;
+ if (catalog) catalog.reset(); // re-enable clickable catalog
+ if (importCount > 0) {
+ // display last layer of last imported dataset
+ target = model.getDefaultTargets()[0];
+ model.selectLayer(target.layers[target.layers.length-1], target.dataset);
+ }
gui.clearProgressMessage();
- clearFiles();
+ importCount = 0;
close();
}
function close() {
- El('#fork-me').hide();
- El('#import-options').hide();
+ clearQueuedFiles();
+ cachedFiles = {};
}
-
- function clearFiles() {
+ function clearQueuedFiles() {
queuedFiles = [];
- El('#dropped-file-list .file-list').empty();
- El('#dropped-file-list').hide();
- El('#import-buttons').hide();
+ gui.container.removeClass('queued-files');
+ gui.container.findChild('.dropped-file-list').empty();
}
- function addFiles(files) {
+ function addFilesToQueue(files) {
var index = {};
queuedFiles = queuedFiles.concat(files).reduce(function(memo, f) {
// filter out unreadable types and dupes
- if (gui.isReadableFileType(f.name) && f.name in index === false) {
+ if (GUI.isReadableFileType(f.name) && f.name in index === false) {
index[f.name] = true;
memo.push(f);
}
return memo;
}, []);
- // sort alphabetically by filename
- queuedFiles.sort(function(a, b) {
- // Sorting on LC filename is a kludge, so Shapefiles with mixed-case
- // extensions are sorted with .shp component before .dbf
- // (When .dbf files are queued first, they are imported as a separate layer.
- // This is so data layers are not later converted into shape layers,
- // e.g. to allow joining a shape layer to its own dbf data table).
- return a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1;
+ }
+
+ // When a Shapefile component is at the head of the queue, move the entire
+ // Shapefile to the front of the queue, sorted in reverse alphabetical order,
+ // (a kludge), so .shp is read before .dbf and .prj
+ // (If a .dbf file is imported before a .shp, it becomes a separate dataset)
+ // TODO: import Shapefile parts without relying on this kludge
+ function sortQueue(queue) {
+ var nextFile = queue[0];
+ var basename, parts;
+ if (!isShapefilePart(nextFile.name)) {
+ return queue;
+ }
+ basename = utils.getFileBase(nextFile.name).toLowerCase();
+ parts = [];
+ queue = queue.filter(function(file) {
+ if (utils.getFileBase(file.name).toLowerCase() == basename) {
+ parts.push(file);
+ return false;
+ }
+ return true;
+ });
+ parts.sort(function(a, b) {
+ // Sorting on LC filename so Shapefiles with mixed-case
+ // extensions are sorted correctly
+ return a.name.toLowerCase() < b.name.toLowerCase() ? 1 : -1;
});
+ return parts.concat(queue);
}
function showQueuedFiles() {
- var list = El('#dropped-file-list .file-list').empty();
- El('#dropped-file-list').show();
+ var list = gui.container.findChild('.dropped-file-list').empty();
queuedFiles.forEach(function(f) {
- El('').text(f.name).appendTo(El("#dropped-file-list .file-list"));
+ El('
').text(f.name).appendTo(list);
});
}
- function receiveFiles(files) {
+ function receiveFilesQuickView(files) {
+ receiveFiles(files, true);
+ }
+
+ function receiveFiles(files, quickView) {
var prevSize = queuedFiles.length;
- addFiles(utils.toArray(files));
+ files = handleZipFiles(utils.toArray(files), quickView);
+ addFilesToQueue(files);
if (queuedFiles.length === 0) return;
gui.enterMode('import');
- if (importCount === 0 && prevSize === 0 && containsImmediateFile(queuedFiles)) {
- // if the first batch of files will be imported, process right away
- submitFiles();
+
+ if (quickView === true) {
+ onSubmit(quickView);
} else {
+ gui.container.addClass('queued-files');
+ El('#path-import-options').classed('hidden', !filesMayContainPaths(queuedFiles));
showQueuedFiles();
- El('#import-buttons').show();
}
}
- // Check if an array of File objects contains a file that should be imported right away
- function containsImmediateFile(files) {
+ function filesMayContainPaths(files) {
return utils.some(files, function(f) {
var type = internal.guessInputFileType(f.name);
- return type == 'shp' || type == 'json';
+ return type == 'shp' || type == 'json' || internal.isZipFile(f.name);
});
}
- function submitFiles() {
- close();
- readNext();
+ function onSubmit(quickView) {
+ gui.container.removeClass('queued-files');
+ gui.container.removeClass('splash-screen');
+ _importOpts = quickView === true ? {} : readImportOpts();
+ procNextQueuedFile();
}
- function readNext() {
- if (queuedFiles.length > 0) {
- readFile(queuedFiles.pop()); // read in rev. alphabetic order, so .shp comes before .dbf
- } else {
- gui.clearMode();
+ function addDataset(dataset) {
+ if (!datasetIsEmpty(dataset)) {
+ model.addDataset(dataset);
+ importCount++;
}
+ procNextQueuedFile();
}
- function getImportOpts() {
- var freeform = El('#import-options .advanced-options').node().value,
- opts = gui.parseFreeformOptions(freeform, 'i');
- opts.no_repair = !El("#repair-intersections-opt").node().checked;
- opts.auto_snap = !!El("#snap-points-opt").node().checked;
- return opts;
+ function datasetIsEmpty(dataset) {
+ return dataset.layers.every(function(lyr) {
+ return internal.getFeatureCount(lyr) === 0;
+ });
}
- function loadFile(file, cb) {
- var reader = new FileReader(),
- isBinary = internal.isBinaryFile(file.name);
- // no callback on error -- fix?
- reader.onload = function(e) {
- cb(null, reader.result);
- };
- if (isBinary) {
- reader.readAsArrayBuffer(file);
+ function procNextQueuedFile() {
+ if (queuedFiles.length === 0) {
+ gui.clearMode();
} else {
- // TODO: improve to handle encodings, etc.
- reader.readAsText(file, 'UTF-8');
+ queuedFiles = sortQueue(queuedFiles);
+ readFile(queuedFiles.shift());
}
}
+ // TODO: support .cpg
+ function isShapefilePart(name) {
+ return /\.(shp|shx|dbf|prj)$/i.test(name);
+ }
+
+ function readImportOpts() {
+ var freeform = El('#import-options .advanced-options').node().value,
+ opts = GUI.parseFreeformOptions(freeform, 'i');
+ opts.no_repair = !El("#repair-intersections-opt").node().checked;
+ opts.snap = !!El("#snap-points-opt").node().checked;
+ return opts;
+ }
+
// @file a File object
function readFile(file) {
- if (internal.isZipFile(file.name)) {
- readZipFile(file);
+ var name = file.name,
+ reader = new FileReader(),
+ useBinary = internal.isSupportedBinaryInputType(name) ||
+ internal.isZipFile(name) ||
+ internal.guessInputFileType(name) == 'json' ||
+ internal.guessInputFileType(name) == 'text';
+
+ reader.addEventListener('loadend', function(e) {
+ if (!reader.result) {
+ handleImportError("Web browser was unable to load the file.", name);
+ } else {
+ importFileContent(name, reader.result);
+ }
+ });
+ if (useBinary) {
+ reader.readAsArrayBuffer(file);
} else {
- loadFile(file, function(err, content) {
- if (err) {
- readNext();
- } else {
- readFileContent(file.name, content);
- }
- });
+ // TODO: consider using "encoding" option, to support CSV files in other encodings than utf8
+ reader.readAsText(file, 'UTF-8');
}
}
- function readFileContent(name, content) {
- var type = internal.guessInputType(name, content),
- importOpts = getImportOpts(),
- matches = findMatchingShp(name),
+ function importFileContent(fileName, content) {
+ var fileType = internal.guessInputType(fileName, content),
+ importOpts = utils.extend({}, _importOpts),
+ matches = findMatchingShp(fileName),
dataset, lyr;
- // TODO: refactor
- if (type == 'dbf' && matches.length > 0) {
+ // Add dbf data to a previously imported .shp file with a matching name
+ // (.shp should have been queued before .dbf)
+ if (fileType == 'dbf' && matches.length > 0) {
// find an imported .shp layer that is missing attribute data
// (if multiple matches, try to use the most recently imported one)
dataset = matches.reduce(function(memo, d) {
@@ -243,62 +308,86 @@ function ImportControl(model, opts) {
// kludge: trigger display of table cells if .shp has null geometry
model.updated({}, lyr, dataset);
}
- readNext();
+ procNextQueuedFile();
return;
}
}
- if (type == 'prj') {
- // assumes that .shp has been imported first
+ if (fileType == 'shx') {
+ // save .shx for use when importing .shp
+ // (queue should be sorted so that .shx is processed before .shp)
+ cachedFiles[fileName.toLowerCase()] = {filename: fileName, content: content};
+ procNextQueuedFile();
+ return;
+ }
+
+ // Add .prj file to previously imported .shp file
+ if (fileType == 'prj') {
matches.forEach(function(d) {
- if (!d.info.input_prj) {
- d.info.input_prj = content;
+ if (!d.info.prj) {
+ d.info.prj = content;
}
});
- readNext();
+ procNextQueuedFile();
return;
}
- importFileContent(type, name, content, importOpts);
+ importNewDataset(fileType, fileName, content, importOpts);
}
- function importFileContent(type, path, content, importOpts) {
+ function importNewDataset(fileType, fileName, content, importOpts) {
var size = content.byteLength || content.length, // ArrayBuffer or string
- showMsg = size > 4e7, // don't show message if dataset is small
delay = 0;
- importOpts.files = [path]; // TODO: try to remove this
- if (showMsg) {
+
+ // show importing message if file is large
+ if (size > 4e7) {
gui.showProgressMessage('Importing');
delay = 35;
}
setTimeout(function() {
var dataset;
+ var input = {};
try {
- dataset = internal.importFileContent(content, path, importOpts);
- dataset.info.no_repair = importOpts.no_repair;
- model.updated({select: true, import: true}, dataset.layers[0], dataset);
- importCount++;
- readNext();
- } catch(e) {
- handleImportError(e, path);
+ input[fileType] = {filename: fileName, content: content};
+ if (fileType == 'shp') {
+ // shx file should already be cached, if it was added together with the shp
+ input.shx = cachedFiles[fileName.replace(/shp$/i, 'shx').toLowerCase()] || null;
+ }
+ dataset = internal.importContent(input, importOpts);
+ // save import options for use by repair control, etc.
+ dataset.info.import_options = importOpts;
+ addDataset(dataset);
+
+ } catch(e) {
+ handleImportError(e, fileName);
}
}, delay);
}
- function handleImportError(e, path) {
+ function handleImportError(e, fileName) {
var msg = utils.isString(e) ? e : e.message;
- if (path) {
- msg = "Error importing " + path + ":\n" + msg;
+ if (fileName) {
+ msg = "Error importing " + fileName + "
" + msg;
}
- clearFiles();
+ clearQueuedFiles();
gui.alert(msg);
console.error(e);
}
- function readZipFile(file) {
- gui.showProgressMessage('Importing');
+ function handleZipFiles(files, quickView) {
+ return files.filter(function(file) {
+ var isZip = internal.isZipFile(file.name);
+ if (isZip) {
+ importZipFile(file, quickView);
+ }
+ return !isZip;
+ });
+ }
+
+ function importZipFile(file, quickView) {
+ // gui.showProgressMessage('Importing');
setTimeout(function() {
- gui.readZipFile(file, function(err, files) {
+ GUI.readZipFile(file, function(err, files) {
if (err) {
handleImportError(err, file.name);
} else {
@@ -307,8 +396,7 @@ function ImportControl(model, opts) {
files = files.filter(function(f) {
return !/\.txt$/i.test(f.name);
});
- addFiles(files);
- readNext();
+ receiveFiles(files, quickView);
}
});
}, 35);
@@ -320,18 +408,20 @@ function ImportControl(model, opts) {
var item = {name: name};
if (isUrl) {
item.url = name;
- item.basename = gui.getUrlFilename(name);
+ item.basename = GUI.getUrlFilename(name);
+
} else {
item.basename = name;
// Assume non-urls are local files loaded via mapshaper-gui
item.url = '/data/' + name;
+ item.url = item.url.replace('/../', '/~/'); // kludge to allow accessing one parent
}
- return gui.isReadableFileType(item.basename) ? item : null;
+ return GUI.isReadableFileType(item.basename) ? item : null;
});
return items.filter(Boolean);
}
- function downloadFiles(paths, opts) {
+ function downloadFiles(paths, quickView) {
var items = prepFilesForDownload(paths);
utils.reduceAsync(items, [], downloadNextFile, function(err, files) {
if (err) {
@@ -339,8 +429,7 @@ function ImportControl(model, opts) {
} else if (!files.length) {
gui.clearMode();
} else {
- addFiles(files);
- submitFiles();
+ receiveFiles(files, quickView);
}
});
}
@@ -354,6 +443,10 @@ function ImportControl(model, opts) {
blob = req.response;
}
});
+ req.addEventListener('progress', function(e) {
+ var pct = e.loaded / e.total;
+ if (catalog) catalog.progress(pct);
+ });
req.addEventListener('loadend', function() {
var err;
if (req.status == 404) {
diff --git a/src/gui/mapshaper-inspection-control.js b/src/gui/mapshaper-inspection-control.js
deleted file mode 100644
index f086bc29e..000000000
--- a/src/gui/mapshaper-inspection-control.js
+++ /dev/null
@@ -1,134 +0,0 @@
-/* @requires mapshaper-gui-lib, mapshaper-popup */
-
-function InspectionControl(model, hit) {
- var _popup = new Popup();
- var _inspecting = false;
- var _pinned = false;
- var _highId = -1;
- var _selectionIds = null;
- var btn = gui.addSidebarButton("#info-icon2").on('click', function() {
- if (_inspecting) turnOff(); else turnOn();
- });
- var _self = new EventDispatcher();
- var _shapes, _lyr;
-
- _self.updateLayer = function(o) {
- var shapes = o.getDisplayLayer().layer.shapes;
- if (_inspecting) {
- // kludge: check if shapes have changed
- if (_shapes == shapes) {
- // kludge: re-display the inspector, in case data changed
- inspect(_highId, _pinned);
- } else {
- _selectionIds = null;
- inspect(-1, false);
- }
- }
- hit.setLayer(o);
- _shapes = shapes;
- _lyr = o;
- };
-
- // replace cli inspect command
- api.inspect = function(lyr, arcs, opts) {
- var ids;
- if (lyr != model.getActiveLayer().layer) {
- error("Only the active layer can be targeted");
- }
- ids = internal.selectFeatures(lyr, arcs, opts);
- if (ids.length === 0) {
- message("No features were selected");
- return;
- }
- _selectionIds = ids;
- turnOn();
- inspect(ids[0], true);
- };
-
- document.addEventListener('keydown', function(e) {
- var kc = e.keyCode, n, id;
- if (!_inspecting) return;
-
- // esc key closes (unless in an editing mode)
- if (e.keyCode == 27 && _inspecting && !gui.getMode()) {
- turnOff();
-
- // arrow keys advance pinned feature unless user is editing text.
- } else if ((kc == 37 || kc == 39) && _pinned && !gui.getInputElement()) {
- n = internal.getFeatureCount(_lyr.getDisplayLayer().layer);
- if (n > 1) {
- if (kc == 37) {
- id = (_highId + n - 1) % n;
- } else {
- id = (_highId + 1) % n;
- }
- inspect(id, true);
- e.stopPropagation();
- }
- }
- }, !!'capture'); // preempt the layer control's arrow key handler
-
- hit.on('click', function(e) {
- var id = e.id;
- var pin = false;
- if (_pinned && id == _highId) {
- // clicking on pinned shape: unpin
- } else if (!_pinned && id > -1) {
- // clicking on unpinned shape while unpinned: pin
- pin = true;
- } else if (_pinned && id > -1) {
- // clicking on unpinned shape while pinned: pin new shape
- pin = true;
- } else if (!_pinned && id == -1) {
- // clicking off the layer while pinned: unpin and deselect
- }
- inspect(id, pin, e.ids);
- });
-
- hit.on('hover', function(e) {
- var id = e.id;
- if (!_inspecting || _pinned) return;
- inspect(id, false, e.ids);
- });
-
- function showInspector(id, editable) {
- var o = _lyr.getDisplayLayer();
- var table = o.layer.data || null;
- var rec = table ? table.getRecordAt(id) : {};
- _popup.show(rec, table, editable);
- }
-
- // @id Id of a feature in the active layer, or -1
- function inspect(id, pin, ids) {
- if (!_inspecting) return;
- if (id > -1) {
- showInspector(id, pin);
- } else {
- _popup.hide();
- }
- _highId = id;
- _pinned = pin;
- _self.dispatchEvent('change', {
- selection_ids: _selectionIds || [],
- hover_ids: ids || [],
- id: id,
- pinned: pin
- });
- }
-
- function turnOn() {
- btn.addClass('selected');
- _inspecting = true;
- hit.start();
- }
-
- function turnOff() {
- btn.removeClass('selected');
- hit.stop();
- _selectionIds = null;
- inspect(-1); // clear the map
- _inspecting = false;
- }
-
- return _self;
-}
diff --git a/src/gui/mapshaper-inspection-control2.js b/src/gui/mapshaper-inspection-control2.js
new file mode 100644
index 000000000..3b9c28c0e
--- /dev/null
+++ b/src/gui/mapshaper-inspection-control2.js
@@ -0,0 +1,131 @@
+/* @requires mapshaper-gui-lib, mapshaper-popup */
+
+function InspectionControl2(gui, hit) {
+ var model = gui.model;
+ var _popup = new Popup(gui, hit.getSwitchHandler(1), hit.getSwitchHandler(-1));
+ var _self = new EventDispatcher();
+
+ // state variables
+ var _pinned = false;
+ var _highId = -1;
+
+ gui.on('interaction_mode_change', function(e) {
+ if (e.mode == 'off') {
+ turnOff();
+ }
+ // TODO: update popup if currently pinned
+ });
+
+ // inspector and label editing aren't fully synced - stop inspecting if label editor starts
+ // REMOVED
+ // gui.on('label_editor_on', function() {
+ // });
+
+ _popup.on('update', function(e) {
+ var d = e.data;
+ d.i = _highId; // need to add record id
+ _self.dispatchEvent('data_change', d);
+ });
+
+ // replace cli inspect command
+ // TODO: support multiple editors on the page
+ // REMOVING gui output for -inspect command
+ /*
+ api.inspect = function(lyr, arcs, opts) {
+ var ids;
+ if (!_target) return; // control is disabled (selected layer is hidden, etc)
+ if (lyr != model.getActiveLayer().layer) {
+ error("Only the active layer can be targeted");
+ }
+ ids = internal.selectFeatures(lyr, arcs, opts);
+ if (ids.length === 0) {
+ message("No features were selected");
+ return;
+ }
+ _selectionIds = ids;
+ turnOn();
+ inspect(ids[0], true);
+ };
+ */
+
+ gui.keyboard.on('keydown', function(evt) {
+ var e = evt.originalEvent;
+ var kc = e.keyCode, n, id;
+ if (!inspecting() || !hit.getHitTarget()) return;
+
+ // esc key closes (unless in an editing mode)
+ if (e.keyCode == 27 && inspecting() && !gui.getMode()) {
+ turnOff();
+ return;
+ }
+
+ if (_pinned && !GUI.getInputElement()) {
+ // an element is selected and user is not editing text
+
+ if (kc == 37 || kc == 39) {
+ // arrow keys advance pinned feature
+ n = internal.getFeatureCount(hit.getHitTarget().layer);
+ if (n > 1) {
+ if (kc == 37) {
+ id = (_highId + n - 1) % n;
+ } else {
+ id = (_highId + 1) % n;
+ }
+ inspect(id, true);
+ e.stopPropagation();
+ }
+ } else if (kc == 8) {
+ // delete key
+ // to help protect against inadvertent deletion, don't delete
+ // when console is open or a popup menu is open
+ if (!gui.getMode() && !gui.consoleIsOpen()) {
+ deletePinnedFeature();
+ }
+ }
+ }
+ }, !!'capture'); // preempt the layer control's arrow key handler
+
+ hit.on('change', function(e) {
+ if (!inspecting()) return;
+ inspect(e.id, e.pinned, e.ids);
+ });
+
+ function showInspector(id, ids, pinned) {
+ var target = hit.getHitTarget();
+ var editable = pinned && gui.interaction.getMode() == 'data';
+ if (target && target.layer.data) {
+ _popup.show(id, ids, target.layer.data, pinned, editable);
+ }
+ }
+
+ // @id Id of a feature in the active layer, or -1
+ function inspect(id, pin, ids) {
+ _pinned = pin;
+ if (id > -1 && inspecting()) {
+ showInspector(id, ids, pin);
+ } else {
+ _popup.hide();
+ }
+ }
+
+ // does the attribute inspector appear on rollover
+ function inspecting() {
+ return gui.interaction && gui.interaction.getMode() != 'off';
+ }
+
+ function turnOff() {
+ inspect(-1); // clear the map
+ }
+
+ function deletePinnedFeature() {
+ var lyr = model.getActiveLayer().layer;
+ console.log("delete; pinned?", _pinned, "id:", _highId);
+ if (!_pinned || _highId == -1) return;
+ lyr.shapes.splice(_highId, 1);
+ if (lyr.data) lyr.data.getRecords().splice(_highId, 1);
+ inspect(-1);
+ model.updated({flags: 'filter'});
+ }
+
+ return _self;
+}
diff --git a/src/gui/mapshaper-interaction-mode-control.js b/src/gui/mapshaper-interaction-mode-control.js
new file mode 100644
index 000000000..0c361e6b1
--- /dev/null
+++ b/src/gui/mapshaper-interaction-mode-control.js
@@ -0,0 +1,192 @@
+/* @require mapshaper-gui-lib */
+
+function InteractionMode(gui) {
+ var buttons, btn1, btn2, menu;
+
+ // all possible menu contents
+ var menus = {
+ standard: ['info', 'data'],
+ labels: ['info', 'data', 'labels', 'location'],
+ points: ['info', 'data', 'location']
+ };
+
+ // mode name -> menu text lookup
+ var labels = {
+ info: 'off', // no data editing, just popup
+ data: 'data',
+ labels: 'labels',
+ location: 'coordinates'
+ };
+
+ // state variables
+ var _editMode = 'info'; // one of labels{} keys
+ var _active = false; // interaction on/off
+ var _menuOpen = false;
+
+ // Only render edit mode button/menu if this option is present
+ if (gui.options.inspectorControl) {
+ buttons = gui.buttons.addDoubleButton('#info-icon2', '#info-menu-icon');
+ btn1 = buttons[0]; // [i] button
+ btn2 = buttons[1]; // submenu button
+ menu = El('div').addClass('nav-sub-menu').appendTo(btn2.node().parentNode);
+
+ menu.on('click', function() {
+ closeMenu(0); // dismiss menu by clicking off an active link
+ });
+
+ btn1.on('click', function() {
+ gui.dispatchEvent('interaction_toggle');
+ });
+
+ btn2.on('click', function() {
+ _menuOpen = true;
+ updateMenu();
+ });
+
+ // triggered by a keyboard shortcut
+ gui.on('interaction_toggle', function() {
+ setActive(!_active);
+ });
+
+ updateVisibility();
+ }
+
+ this.getMode = getInteractionMode;
+
+ this.setActive = setActive;
+
+ this.setMode = function(mode) {
+ // TODO: check that this mode is valid for the current dataset
+ if (mode in labels) {
+ setMode(mode);
+ }
+ };
+
+ this.modeUsesDrag = function(name) {
+ return name == 'location' || name == 'labels';
+ };
+
+ this.modeUsesClick = function(name) {
+ return name == 'data' || name == 'info'; // click used to pin popup
+ };
+
+ gui.model.on('update', function(e) {
+ // change mode if active layer doesn't support the current mode
+ updateCurrentMode();
+ if (_menuOpen) {
+ updateMenu();
+ }
+ }, null, -1); // low priority?
+
+ function getAvailableModes() {
+ var o = gui.model.getActiveLayer();
+ if (!o || !o.layer) {
+ return menus.standard; // TODO: more sensible handling of missing layer
+ }
+ if (internal.layerHasLabels(o.layer)) {
+ return menus.labels;
+ }
+ if (internal.layerHasPoints(o.layer)) {
+ return menus.points;
+ }
+ return menus.standard;
+ }
+
+ function getInteractionMode() {
+ return _active ? _editMode : 'off';
+ }
+
+ function renderMenu(modes) {
+ menu.empty();
+ El('div').addClass('nav-menu-item').text('interactive editing:').appendTo(menu);
+ modes.forEach(function(mode) {
+ var link = El('div').addClass('nav-menu-item nav-menu-link').attr('data-name', mode).text(labels[mode]).appendTo(menu);
+ link.on('click', function(e) {
+ if (_editMode != mode) {
+ setMode(mode);
+ closeMenu(500);
+ e.stopPropagation();
+ }
+ });
+ });
+ }
+
+ // if current editing mode is not available, switch to another mode
+ function updateCurrentMode() {
+ var modes = getAvailableModes();
+ if (modes.indexOf(_editMode) == -1) {
+ setMode(modes[0]);
+ }
+ }
+
+ function updateMenu() {
+ if (menu) {
+ renderMenu(getAvailableModes());
+ updateModeDisplay();
+ updateVisibility();
+ }
+ }
+
+ function openMenu() {
+ _menuOpen = true;
+ updateVisibility();
+ }
+
+ function closeMenu(delay) {
+ setTimeout(function() {
+ _menuOpen = false;
+ updateVisibility();
+ }, delay || 0);
+ }
+
+ function setMode(mode) {
+ var changed = mode != _editMode;
+ if (changed) {
+ _editMode = mode;
+ updateMenu();
+ onModeChange();
+ }
+ }
+
+ function setActive(active) {
+ if (active != _active) {
+ _active = !!active;
+ _menuOpen = false; // make sure menu does not stay open when button toggles off
+ updateVisibility();
+ onModeChange();
+ }
+ }
+
+ function onModeChange() {
+ gui.dispatchEvent('interaction_mode_change', {mode: getInteractionMode()});
+ }
+
+ function updateVisibility() {
+ if (!menu) return;
+ // menu
+ if (_menuOpen && _active) {
+ menu.show();
+ } else {
+ menu.hide();
+ }
+ // button
+ if (_menuOpen || !_active) {
+ btn2.hide();
+ } else {
+ btn2.show();
+ }
+ btn1.classed('selected', _active);
+ }
+
+ function updateModeDisplay() {
+ El.findAll('.nav-menu-item').forEach(function(el) {
+ el = El(el);
+ el.classed('selected', el.attr('data-name') == _editMode);
+ });
+ }
+
+ function initLink(label) {
+ return El('div').addClass('edit-mode-link').text(label);
+ }
+
+}
diff --git a/src/gui/mapshaper-keyboard.js b/src/gui/mapshaper-keyboard.js
new file mode 100644
index 000000000..5fc3439c0
--- /dev/null
+++ b/src/gui/mapshaper-keyboard.js
@@ -0,0 +1,19 @@
+
+function KeyboardEvents(gui) {
+ var self = this;
+ document.addEventListener('keydown', function(e) {
+ if (!GUI.isActiveInstance(gui)) return;
+ self.dispatchEvent('keydown', {originalEvent: e});
+ });
+
+ this.onMenuSubmit = function(menuEl, cb) {
+ gui.on('enter_key', function(e) {
+ if (menuEl.visible()) {
+ e.originalEvent.stopPropagation();
+ cb();
+ }
+ });
+ };
+}
+
+utils.inherit(KeyboardEvents, EventDispatcher);
diff --git a/src/gui/mapshaper-layer-control.js b/src/gui/mapshaper-layer-control.js
index cfb418c07..e319557ca 100644
--- a/src/gui/mapshaper-layer-control.js
+++ b/src/gui/mapshaper-layer-control.js
@@ -1,179 +1,341 @@
-/* @require mapshaper-gui-lib */
+/* @require mapshaper-gui-lib mapshaper-dom-cache */
-function LayerControl(model, map) {
- var el = El("#layer-control").on('click', gui.handleDirectEvent(gui.clearMode));
- var buttonLabel = El('#layer-control-btn .layer-name');
+function LayerControl(gui) {
+ var map = gui.map;
+ var model = gui.model;
+ var el = gui.container.findChild(".layer-control").on('click', GUI.handleDirectEvent(gui.clearMode));
+ var btn = gui.container.findChild('.layer-control-btn');
+ var buttonLabel = btn.findChild('.layer-name');
var isOpen = false;
- var pinnedLyr = null;
+ var cache = new DomCache();
+ var pinAll = el.findChild('.pin-all'); // button for toggling layer visibility
- new ModeButton('#layer-control-btn .header-btn', 'layer_menu');
- gui.addMode('layer_menu', turnOn, turnOff);
+ // layer repositioning
+ var dragTargetId = null;
+ var dragging = false;
+ var layerOrderSlug;
+
+ gui.addMode('layer_menu', turnOn, turnOff, btn.findChild('.header-btn'));
model.on('update', function(e) {
- updateBtn();
+ updateMenuBtn();
if (isOpen) render();
});
+ el.on('mouseup', stopDragging);
+ el.on('mouseleave', stopDragging);
+
+ // init layer visibility button
+ pinAll.on('click', function() {
+ var allOn = testAllLayersPinned();
+ model.getLayers().forEach(function(target) {
+ map.setLayerVisibility(target, !allOn);
+ });
+ El.findAll('.pinnable', el.node()).forEach(function(item) {
+ El(item).classed('pinned', !allOn);
+ });
+ map.redraw();
+ });
+
+
+ function updatePinAllButton() {
+ pinAll.classed('pinned', testAllLayersPinned());
+ }
+
+ function testAllLayersPinned() {
+ var yes = true;
+ model.forEachLayer(function(lyr, dataset) {
+ if (isPinnable(lyr) && !map.isVisibleLayer(lyr)) {
+ yes = false;
+ }
+ });
+ return yes;
+ }
+
+ function findLayerById(id) {
+ return model.findLayer(function(lyr, dataset) {
+ return lyr.menu_id == id;
+ });
+ }
+
+ function getLayerOrderSlug() {
+ return internal.sortLayersForMenuDisplay(model.getLayers()).map(function(o) {
+ return map.isVisibleLayer(o.layer) ? o.layer.menu_id : '';
+ }).join('');
+ }
+
+ function clearClass(name) {
+ var targ = el.findChild('.' + name);
+ if (targ) targ.removeClass(name);
+ }
+
+ function stopDragging() {
+ clearClass('dragging');
+ clearClass('drag-target');
+ clearClass('insert-above');
+ clearClass('insert-below');
+ dragTargetId = layerOrderSlug = null;
+ if (dragging) {
+ render(); // in case menu changed...
+ dragging = false;
+ }
+ }
+
+ function insertLayer(dragId, dropId, above) {
+ var dragLyr = findLayerById(dragId);
+ var dropLyr = findLayerById(dropId);
+ var slug;
+ if (dragId == dropId) return;
+ dragLyr.layer.stack_id = dropLyr.layer.stack_id + (above ? 0.5 : -0.5);
+ slug = getLayerOrderSlug();
+ if (slug != layerOrderSlug) {
+ layerOrderSlug = slug;
+ map.redraw();
+ }
+ }
+
function turnOn() {
isOpen = true;
- // set max layer menu height
+ el.findChild('div.info-box-scrolled').css('max-height', El('body').height() - 80);
render();
- El('#layer-control div.info-box-scrolled').css('max-height', El('body').height() - 80);
el.show();
}
function turnOff() {
+ stopDragging();
isOpen = false;
el.hide();
}
- function updateBtn() {
+ function updateMenuBtn() {
var name = model.getActiveLayer().layer.name || "[unnamed layer]";
buttonLabel.html(name + " ▼");
}
function render() {
- var list = El('#layer-control .layer-list');
- var pinnable = 0;
- if (isOpen) {
- list.hide().empty();
- model.forEachLayer(function(lyr, dataset) {
- if (isPinnable(lyr)) pinnable++;
- });
- if (pinnable === 0 && pinnedLyr) {
- clearPin(); // a layer has been deleted...
+ var list = el.findChild('.layer-list');
+ var uniqIds = {};
+ var pinnableCount = 0;
+ var layerCount = 0;
+ list.empty();
+ model.forEachLayer(function(lyr, dataset) {
+ // Assign a unique id to each layer, so html strings
+ // can be used as unique identifiers for caching rendered HTML, and as
+ // an id for layer menu event handlers
+ if (!lyr.menu_id || uniqIds[lyr.menu_id]) {
+ lyr.menu_id = utils.getUniqueName();
}
- model.forEachLayer(function(lyr, dataset) {
- list.appendChild(renderLayer(lyr, dataset, pinnable > 1 && isPinnable(lyr)));
- });
- list.show();
- }
- }
+ uniqIds[lyr.menu_id] = true;
+ if (isPinnable(lyr)) pinnableCount++;
+ layerCount++;
+ });
- function describeLyr(lyr) {
- var n = internal.getFeatureCount(lyr),
- str, type;
- if (lyr.data && !lyr.shapes) {
- type = 'data record';
- } else if (lyr.geometry_type) {
- type = lyr.geometry_type + ' feature';
- }
- if (type) {
- str = utils.format('%,d %s%s', n, type, utils.pluralSuffix(n));
+ if (pinnableCount < 2) {
+ pinAll.hide();
} else {
- str = "[empty]";
+ pinAll.show();
+ updatePinAllButton();
}
- return str;
- }
- function describeSrc(lyr, dataset) {
- var inputs = dataset.info.input_files;
- var file = inputs && inputs[0] || '';
- if (utils.endsWith(file, '.shp') && !lyr.data && lyr == dataset.layers[0]) {
- file += " (missing .dbf)";
- }
- return file;
+ internal.sortLayersForMenuDisplay(model.getLayers()).forEach(function(o) {
+ var lyr = o.layer;
+ var opts = {
+ show_source: layerCount < 5,
+ pinnable: pinnableCount > 1 && isPinnable(lyr)
+ };
+ var html, element;
+ html = renderLayer(lyr, o.dataset, opts);
+ if (cache.contains(html)) {
+ element = cache.use(html);
+ } else {
+ element = El('div').html(html).firstChild();
+ initMouseEvents(element, lyr.menu_id, opts.pinnable);
+ cache.add(html, element);
+ }
+ list.appendChild(element);
+ });
}
- function getDisplayName(name) {
- return name || '[unnamed]';
- }
+ cache.cleanup();
+
+ function renderLayer(lyr, dataset, opts) {
+ var warnings = getWarnings(lyr, dataset);
+ var classes = 'layer-item';
+ var entry, html;
- function setPin(lyr, dataset) {
- if (pinnedLyr != lyr) {
- clearPin();
- map.setReferenceLayer(lyr, dataset);
- pinnedLyr = lyr;
- el.addClass('visible-pin');
+ if (opts.pinnable) classes += ' pinnable';
+ if (map.isActiveLayer(lyr)) classes += ' active';
+ if (map.isVisibleLayer(lyr)) classes += ' pinned';
+
+ html = '
';
+ html += rowHTML('name', '
' + getDisplayName(lyr.name) + '', 'row1');
+ if (opts.show_source) {
+ html += rowHTML('source file', describeSrc(lyr, dataset) || 'n/a');
+ }
+ html += rowHTML('contents', describeLyr(lyr));
+ if (warnings) {
+ html += rowHTML('problems', warnings, 'layer-problems');
}
+ html += '
';
+ if (opts.pinnable) {
+ html += '
';
+ html += '
';
+ }
+ html += '
';
+ return html;
}
- function clearPin() {
- if (pinnedLyr) {
- Elements('.layer-item.pinned').forEach(function(el) {
- el.removeClass('pinned');
- });
- el.removeClass('visible-pin');
- pinnedLyr = null;
- map.setReferenceLayer(null);
+ function initMouseEvents(entry, id, pinnable) {
+ entry.on('mouseover', init);
+ function init() {
+ entry.removeEventListener('mouseover', init);
+ initMouseEvents2(entry, id, pinnable);
}
}
- function isPinnable(lyr) {
- return !!lyr.geometry_type;
+ function initLayerDragging(entry, id) {
+
+ // support layer drag-drop
+ entry.on('mousemove', function(e) {
+ var rect, insertionClass;
+ if (!e.buttons && (dragging || dragTargetId)) { // button is up
+ stopDragging();
+ }
+ if (e.buttons && !dragTargetId) {
+ dragTargetId = id;
+ entry.addClass('drag-target');
+ }
+ if (!dragTargetId) {
+ return;
+ }
+ if (dragTargetId != id) {
+ // signal to redraw menu later; TODO: improve
+ dragging = true;
+ }
+ rect = entry.node().getBoundingClientRect();
+ insertionClass = e.pageY - rect.top < rect.height / 2 ? 'insert-above' : 'insert-below';
+ if (!entry.hasClass(insertionClass)) {
+ clearClass('dragging');
+ clearClass('insert-above');
+ clearClass('insert-below');
+ entry.addClass('dragging');
+ entry.addClass(insertionClass);
+ insertLayer(dragTargetId, id, insertionClass == 'insert-above');
+ }
+ });
}
- function renderLayer(lyr, dataset, pinnable) {
- var editLyr = model.getActiveLayer().layer;
- var entry = El('div').addClass('layer-item').classed('active', lyr == editLyr);
- var html = rowHTML('name', '' + getDisplayName(lyr.name) + '', 'row1');
- html += rowHTML('source file', describeSrc(lyr, dataset) || 'n/a');
- html += rowHTML('contents', describeLyr(lyr));
- html += '';
- if (pinnable) {
- html += '';
- html += '';
- }
- entry.html(html);
+ function initMouseEvents2(entry, id, pinnable) {
- // init delete button
- entry.findChild('img.close-btn').on('mouseup', function(e) {
- e.stopPropagation();
- deleteLayer(lyr, dataset);
- });
+ initLayerDragging(entry, id);
- if (pinnable) {
- if (pinnedLyr == lyr) {
- entry.addClass('pinned');
+ // init delete button
+ GUI.onClick(entry.findChild('img.close-btn'), function(e) {
+ var target = findLayerById(id);
+ e.stopPropagation();
+ if (map.isVisibleLayer(target.layer)) {
+ // TODO: check for double map refresh after model.deleteLayer() below
+ map.setLayerVisibility(target, false);
}
+ model.deleteLayer(target.layer, target.dataset);
+ });
+ if (pinnable) {
// init pin button
- entry.findChild('img.pinned').on('mouseup', function(e) {
+ GUI.onClick(entry.findChild('img.unpinned'), function(e) {
+ var target = findLayerById(id);
e.stopPropagation();
- if (lyr == pinnedLyr) {
- clearPin();
+ if (map.isVisibleLayer(target.layer)) {
+ map.setLayerVisibility(target, false);
+ entry.removeClass('pinned');
} else {
- setPin(lyr, dataset);
+ map.setLayerVisibility(target, true);
entry.addClass('pinned');
}
+ updatePinAllButton();
+ map.redraw();
+ });
+
+ // catch click event on pin button
+ GUI.onClick(entry.findChild('img.unpinned'), function(e) {
+ e.stopPropagation();
});
}
// init name editor
new ClickText2(entry.findChild('.layer-name'))
.on('change', function(e) {
+ var target = findLayerById(id);
var str = cleanLayerName(this.value());
this.value(getDisplayName(str));
- lyr.name = str;
- updateBtn();
+ target.layer.name = str;
+ updateMenuBtn();
});
+
// init click-to-select
- gui.onClick(entry, function() {
- if (!gui.getInputElement()) { // don't select if user is typing
+ GUI.onClick(entry, function() {
+ var target = findLayerById(id);
+ // don't select if user is typing or dragging
+ if (!GUI.getInputElement() && !dragging) {
gui.clearMode();
- if (lyr != editLyr) {
- model.updated({select: true}, lyr, dataset);
+ if (!map.isActiveLayer(target.layer)) {
+ model.updated({select: true}, target.layer, target.dataset);
}
}
});
- return entry;
}
- function deleteLayer(lyr, dataset) {
- var active;
- if (lyr == pinnedLyr) {
- clearPin();
+ function describeLyr(lyr) {
+ var n = internal.getFeatureCount(lyr),
+ str, type;
+ if (lyr.data && !lyr.shapes) {
+ type = 'data record';
+ } else if (lyr.geometry_type) {
+ type = lyr.geometry_type + ' feature';
}
- model.deleteLayer(lyr, dataset);
- if (model.isEmpty()) {
- // refresh browser if deleted layer was the last layer
- window.location.href = window.location.href.toString();
+ if (type) {
+ str = utils.format('%,d %s%s', n, type, utils.pluralSuffix(n));
} else {
- // trigger update event
- active = model.getActiveLayer();
- model.selectLayer(active.layer, active.dataset);
+ str = "[empty]";
+ }
+ return str;
+ }
+
+ function getWarnings(lyr, dataset) {
+ var file = getSourceFile(lyr, dataset);
+ var missing = [];
+ var msg;
+ if (utils.endsWith(file, '.shp') && lyr == dataset.layers[0]) {
+ if (!lyr.data) {
+ missing.push('.dbf');
+ }
+ if (!dataset.info.prj && !dataset.info.crs) {
+ missing.push('.prj');
+ }
+ }
+ if (missing.length) {
+ msg = 'missing ' + missing.join(' and ') + ' data';
}
+ return msg;
}
+ function getSourceFile(lyr, dataset) {
+ var inputs = dataset.info.input_files;
+ return inputs && inputs[0] || '';
+ }
+
+ function describeSrc(lyr, dataset) {
+ return getSourceFile(lyr, dataset);
+ }
+
+ function getDisplayName(name) {
+ return name || '[unnamed]';
+ }
+
+ function isPinnable(lyr) {
+ return internal.layerHasGeometry(lyr) || internal.layerHasFurniture(lyr);
+ }
+
+
function cleanLayerName(raw) {
return raw.replace(/[\n\t/\\]/g, '')
.replace(/^[\.\s]+/, '').replace(/[\.\s]+$/, '');
diff --git a/src/gui/mapshaper-layer-sorting.js b/src/gui/mapshaper-layer-sorting.js
new file mode 100644
index 000000000..85f1827d0
--- /dev/null
+++ b/src/gui/mapshaper-layer-sorting.js
@@ -0,0 +1,24 @@
+/* @requires mapshaper-gui-lib */
+
+
+internal.updateLayerStackOrder = function(layers) {
+ // 1. assign ascending ids to unassigned layers above the range of other layers
+ layers.forEach(function(o, i) {
+ if (!o.layer.stack_id) o.layer.stack_id = 1e6 + i;
+ });
+ // 2. sort in ascending order
+ layers.sort(function(a, b) {
+ return a.layer.stack_id - b.layer.stack_id;
+ });
+ // 3. assign consecutve ids
+ layers.forEach(function(o, i) {
+ o.layer.stack_id = i + 1;
+ });
+ return layers;
+};
+
+internal.sortLayersForMenuDisplay = function(layers) {
+ layers = internal.updateLayerStackOrder(layers);
+ return layers.reverse();
+};
+
diff --git a/src/gui/mapshaper-layer-stack.js b/src/gui/mapshaper-layer-stack.js
new file mode 100644
index 000000000..609f60075
--- /dev/null
+++ b/src/gui/mapshaper-layer-stack.js
@@ -0,0 +1,94 @@
+/* @requires
+mapshaper-svg-display
+mapshaper-canvas
+mapshaper-map-style
+*/
+
+function LayerStack(gui, container, ext, mouse) {
+ var el = El(container),
+ _activeCanv = new DisplayCanvas().appendTo(el), // data layer shapes
+ _overlayCanv = new DisplayCanvas().appendTo(el), // data layer shapes
+ _overlay2Canv = new DisplayCanvas().appendTo(el), // line intersection dots
+ _svg = new SvgDisplayLayer(gui, ext, mouse).appendTo(el), // labels, _ext;
+ _furniture = new SvgDisplayLayer(gui, ext, null).appendTo(el), // scalebar, etc
+ _ext = ext;
+
+ // don't let furniture container block events to symbol layers
+ _furniture.css('pointer-events', 'none');
+
+ this.drawOverlay2Layer = function(lyr) {
+ drawSingleCanvasLayer(lyr, _overlay2Canv);
+ };
+
+ this.drawOverlayLayer = function(lyr) {
+ drawSingleCanvasLayer(lyr, _overlayCanv);
+ };
+
+ this.drawContentLayers = function(layers, onlyNav) {
+ _activeCanv.prep(_ext);
+ if (!onlyNav) {
+ _svg.clear();
+ }
+ layers.forEach(function(target) {
+ if (layerUsesCanvas(target.layer)) {
+ drawCanvasLayer(target, _activeCanv);
+ }
+ if (layerUsesSVG(target.layer)) {
+ drawSvgLayer(target, onlyNav);
+ }
+ });
+ };
+
+ this.drawFurnitureLayers = function(layers, onlyNav) {
+ if (!onlyNav) {
+ _furniture.clear();
+ }
+ layers.forEach(function(target) {
+ if (onlyNav) {
+ _furniture.reposition(target, 'furniture');
+ } else {
+ _furniture.drawLayer(target, 'furniture');
+ }
+ });
+ };
+
+ function layerUsesCanvas(layer) {
+ // TODO: return false if a label layer does not have dots
+ return !internal.layerHasSvgSymbols(layer);
+ }
+
+ function layerUsesSVG(layer) {
+ return internal.layerHasLabels(layer) || internal.layerHasSvgSymbols(layer);
+ }
+
+ function drawCanvasLayer(target, canv) {
+ if (target.style.type == 'outline') {
+ drawOutlineLayerToCanvas(target, canv, ext);
+ } else {
+ drawStyledLayerToCanvas(target, canv, ext);
+ }
+ }
+
+ function drawSvgLayer(target, onlyNav) {
+ var type;
+ if (internal.layerHasLabels(target.layer)) {
+ type = 'label';
+ } else if (internal.layerHasSvgSymbols(target.layer)) {
+ type = 'symbol';
+ }
+ if (onlyNav) {
+ _svg.reposition(target, type);
+ } else {
+ _svg.drawLayer(target, type);
+ }
+ }
+
+ function drawSingleCanvasLayer(target, canv) {
+ if (!target) {
+ canv.hide();
+ } else {
+ canv.prep(_ext);
+ drawCanvasLayer(target, canv);
+ }
+ }
+}
diff --git a/src/gui/mapshaper-map-extent.js b/src/gui/mapshaper-map-extent.js
index ec8acf4bc..80d3b2f8c 100644
--- a/src/gui/mapshaper-map-extent.js
+++ b/src/gui/mapshaper-map-extent.js
@@ -1,46 +1,40 @@
/* @requires mapshaper-gui-lib */
-function MapExtent(el) {
- var _position = new ElementPosition(el),
- _scale = 1,
- _cx,
- _cy,
- _contentBounds;
-
- _position.on('resize', function() {
- this.dispatchEvent('change');
- this.dispatchEvent('navigate');
- this.dispatchEvent('resize');
- }, this);
-
- this.reset = function(force) {
- this.recenter(_contentBounds.centerX(), _contentBounds.centerY(), 1, force);
+function MapExtent(_position) {
+ var _scale = 1,
+ _cx, _cy, // center in geographic units
+ _contentBounds,
+ _self = this,
+ _frame;
+
+ _position.on('resize', function(e) {
+ if (_contentBounds) {
+ onChange({resize: true});
+ }
+ });
+
+ this.reset = function() {
+ recenter(_contentBounds.centerX(), _contentBounds.centerY(), 1, {reset: true});
};
- this.recenter = function(cx, cy, scale, force) {
- if (!scale) scale = _scale;
- if (force || !(cx == _cx && cy == _cy && scale == _scale)) {
- _cx = cx;
- _cy = cy;
- _scale = scale;
- this.dispatchEvent('change');
- this.dispatchEvent('navigate');
- }
+ this.home = function() {
+ recenter(_contentBounds.centerX(), _contentBounds.centerY(), 1);
};
this.pan = function(xpix, ypix) {
var t = this.getTransform();
- this.recenter(_cx - xpix / t.mx, _cy - ypix / t.my);
+ recenter(_cx - xpix / t.mx, _cy - ypix / t.my);
};
- // Zoom to @scale (a multiple of the map's full scale)
+ // Zoom to @w (width of the map viewport in coordinates)
// @xpct, @ypct: optional focus, [0-1]...
- this.rescale = function(scale, xpct, ypct) {
+ this.zoomToExtent = function(w, xpct, ypct) {
if (arguments.length < 3) {
xpct = 0.5;
ypct = 0.5;
}
var b = this.getBounds(),
+ scale = limitScale(b.width() / w * _scale),
fx = b.xmin + xpct * b.width(),
fy = b.ymax - ypct * b.height(),
dx = b.centerX() - fx,
@@ -50,19 +44,26 @@ function MapExtent(el) {
dy2 = dy * ds,
cx = fx + dx2,
cy = fy + dy2;
- this.recenter(cx, cy, scale);
+ recenter(cx, cy, scale);
+ };
+
+ this.zoomByPct = function(pct, xpct, ypct) {
+ this.zoomToExtent(this.getBounds().width() / pct, xpct, ypct);
};
this.resize = _position.resize;
this.width = _position.width;
this.height = _position.height;
this.position = _position.position;
+ this.recenter = recenter;
// get zoom factor (1 == full extent, 2 == 2x zoom, etc.)
this.scale = function() {
return _scale;
};
+ this.maxScale = maxScale;
+
this.getPixelSize = function() {
return 1 / this.getTransform().mx;
};
@@ -80,51 +81,122 @@ function MapExtent(el) {
this.getBounds = function() {
if (!_contentBounds) return new Bounds();
- return centerAlign(calcBounds(_cx, _cy, _scale));
+ return calcBounds(_cx, _cy, _scale);
};
// Update the extent of 'full' zoom without navigating the current view
this.setBounds = function(b) {
var prev = _contentBounds;
- _contentBounds = b;
+ if (!b.hasBounds()) return; // kludge
+ _contentBounds = _frame ? b : padBounds(b, 4); // padding if not in frame mode
if (prev) {
- _scale = _scale * centerAlign(b).width() / centerAlign(prev).width();
+ _scale = _scale * fillOut(_contentBounds).width() / fillOut(prev).width();
} else {
_cx = b.centerX();
_cy = b.centerY();
}
};
- function getPadding(size) {
- return size * 0.020 + 4;
+ this.translateCoords = function(x, y) {
+ return this.getTransform().transform(x, y);
+ };
+
+ this.setFrame = function(frame) {
+ _frame = frame || null;
+ };
+
+ this.getFrame = function() {
+ return _frame || null;
+ };
+
+ this.getSymbolScale = function() {
+ if (!_frame) return 0;
+ var bounds = new Bounds(_frame.bbox);
+ var bounds2 = bounds.clone().transform(this.getTransform());
+ return bounds2.width() / _frame.width;
+ };
+
+ this.translatePixelCoords = function(x, y) {
+ return this.getTransform().invert().transform(x, y);
+ };
+
+ function recenter(cx, cy, scale, data) {
+ scale = scale ? limitScale(scale) : _scale;
+ if (!(cx == _cx && cy == _cy && scale == _scale)) {
+ _cx = cx;
+ _cy = cy;
+ _scale = scale;
+ onChange(data);
+ }
+ }
+
+ function onChange(data) {
+ data = data || {};
+ _self.dispatchEvent('change', data);
+ }
+
+ // stop zooming before rounding errors become too obvious
+ function maxScale() {
+ var minPixelScale = 1e-16;
+ var xmax = maxAbs(_contentBounds.xmin, _contentBounds.xmax, _contentBounds.centerX());
+ var ymax = maxAbs(_contentBounds.ymin, _contentBounds.ymax, _contentBounds.centerY());
+ var xscale = _contentBounds.width() / _position.width() / xmax / minPixelScale;
+ var yscale = _contentBounds.height() / _position.height() / ymax / minPixelScale;
+ return Math.min(xscale, yscale);
+ }
+
+ function maxAbs() {
+ return Math.max.apply(null, utils.toArray(arguments).map(Math.abs));
+ }
+
+ function limitScale(scale) {
+ return Math.min(scale, maxScale());
}
function calcBounds(cx, cy, scale) {
- var w = _contentBounds.width() / scale,
- h = _contentBounds.height() / scale;
+ var bounds, w, h;
+ if (_frame) {
+ bounds = fillOutFrameBounds(_frame);
+ } else {
+ bounds = fillOut(_contentBounds);
+ }
+ w = bounds.width() / scale;
+ h = bounds.height() / scale;
return new Bounds(cx - w/2, cy - h/2, cx + w/2, cy + h/2);
}
- // Receive: Geographic bounds of content to be centered in the map
- // Return: Geographic bounds of map window centered on @_contentBounds,
- // with padding applied
- function centerAlign(_contentBounds) {
- var bounds = _contentBounds.clone(),
- wpix = _position.width(),
- hpix = _position.height(),
- xmarg = getPadding(wpix),
- ymarg = getPadding(hpix),
- xpad, ypad;
- wpix -= 2 * xmarg;
- hpix -= 2 * ymarg;
+ // Calculate viewport bounds from frame data
+ function fillOutFrameBounds(frame) {
+ var bounds = new Bounds(frame.bbox);
+ var kx = _position.width() / frame.width;
+ var ky = _position.height() / frame.height;
+ bounds.scale(kx, ky);
+ return bounds;
+ }
+
+ function padBounds(b, margin) {
+ var wpix = _position.width() - 2 * margin,
+ hpix = _position.height() - 2 * margin,
+ xpad, ypad, b2;
if (wpix <= 0 || hpix <= 0) {
return new Bounds(0, 0, 0, 0);
}
- bounds.fillOut(wpix / hpix);
- xpad = bounds.width() / wpix * xmarg;
- ypad = bounds.height() / hpix * ymarg;
- bounds.padBounds(xpad, ypad, xpad, ypad);
- return bounds;
+ b = b.clone();
+ b2 = b.clone();
+ b2.fillOut(wpix / hpix);
+ xpad = b2.width() / wpix * margin;
+ ypad = b2.height() / hpix * margin;
+ b.padBounds(xpad, ypad, xpad, ypad);
+ return b;
+ }
+
+ // Pad bounds vertically or horizontally to match viewport aspect ratio
+ function fillOut(b) {
+ var wpix = _position.width(),
+ hpix = _position.height();
+ b = b.clone();
+ b.fillOut(wpix / hpix);
+ return b;
}
}
diff --git a/src/gui/mapshaper-map-nav.js b/src/gui/mapshaper-map-nav.js
index bef3b3e9e..0e751bd91 100644
--- a/src/gui/mapshaper-map-nav.js
+++ b/src/gui/mapshaper-map-nav.js
@@ -3,36 +3,51 @@ mapshaper-gui-lib
mapshaper-highlight-box
*/
-gui.addSidebarButton = function(iconId) {
- var btn = El('div').addClass('nav-btn')
- .on('dblclick', function(e) {e.stopPropagation();}); // block dblclick zoom
- btn.appendChild(iconId);
- btn.appendTo('#nav-buttons');
- return btn;
-};
-
-function MapNav(root, ext, mouse) {
+function MapNav(gui, ext, mouse) {
var wheel = new MouseWheel(mouse),
zoomBox = new HighlightBox('body'),
- buttons = El('div').id('nav-buttons').appendTo(root),
zoomTween = new Tween(Tween.sineInOut),
shiftDrag = false,
- zoomScale = 2.5,
- dragStartEvt, _fx, _fy; // zoom foci, [0,1]
+ zoomScale = 1.5,
+ zoomScaleMultiplier = 1,
+ inBtn, outBtn,
+ dragStartEvt,
+ _fx, _fy; // zoom foci, [0,1]
- gui.addSidebarButton("#home-icon").on('click', function() {ext.reset();});
- gui.addSidebarButton("#zoom-in-icon").on('click', zoomIn);
- gui.addSidebarButton("#zoom-out-icon").on('click', zoomOut);
+ this.setZoomFactor = function(k) {
+ zoomScaleMultiplier = k || 1;
+ };
+
+ if (gui.options.homeControl) {
+ gui.buttons.addButton("#home-icon").on('click', function() {
+ if (disabled()) return;
+ gui.dispatchEvent('map_reset');
+ });
+ }
+
+ if (gui.options.zoomControl) {
+ inBtn = gui.buttons.addButton("#zoom-in-icon").on('click', zoomIn);
+ outBtn = gui.buttons.addButton("#zoom-out-icon").on('click', zoomOut);
+ ext.on('change', function() {
+ inBtn.classed('disabled', ext.scale() >= ext.maxScale());
+ });
+ }
+
+ gui.on('map_reset', function() {
+ ext.home();
+ });
zoomTween.on('change', function(e) {
- ext.rescale(e.value, _fx, _fy);
+ ext.zoomToExtent(e.value, _fx, _fy);
});
mouse.on('dblclick', function(e) {
- zoomByPct(zoomScale, e.x / ext.width(), e.y / ext.height());
+ if (disabled()) return;
+ zoomByPct(1 + zoomScale * zoomScaleMultiplier, e.x / ext.width(), e.y / ext.height());
});
mouse.on('dragstart', function(e) {
+ if (disabled()) return;
shiftDrag = !!e.shiftKey;
if (shiftDrag) {
dragStartEvt = e;
@@ -40,6 +55,7 @@ function MapNav(root, ext, mouse) {
});
mouse.on('drag', function(e) {
+ if (disabled()) return;
if (shiftDrag) {
zoomBox.show(e.pageX, e.pageY, dragStartEvt.pageX, dragStartEvt.pageY);
} else {
@@ -49,6 +65,7 @@ function MapNav(root, ext, mouse) {
mouse.on('dragend', function(e) {
var bounds;
+ if (disabled()) return;
if (shiftDrag) {
shiftDrag = false;
bounds = new Bounds(e.x, e.y, dragStartEvt.x, dragStartEvt.y);
@@ -60,17 +77,24 @@ function MapNav(root, ext, mouse) {
});
wheel.on('mousewheel', function(e) {
- var k = 1 + (0.11 * e.multiplier),
+ var k = 1 + (0.11 * e.multiplier * zoomScaleMultiplier),
delta = e.direction > 0 ? k : 1 / k;
- ext.rescale(ext.scale() * delta, e.x / ext.width(), e.y / ext.height());
+ if (disabled()) return;
+ ext.zoomByPct(delta, e.x / ext.width(), e.y / ext.height());
});
+ function disabled() {
+ return !!gui.options.disableNavigation;
+ }
+
function zoomIn() {
- zoomByPct(zoomScale, 0.5, 0.5);
+ if (disabled()) return;
+ zoomByPct(1 + zoomScale * zoomScaleMultiplier, 0.5, 0.5);
}
function zoomOut() {
- zoomByPct(1/zoomScale, 0.5, 0.5);
+ if (disabled()) return;
+ zoomByPct(1/(1 + zoomScale * zoomScaleMultiplier), 0.5, 0.5);
}
// @box Bounds with pixels from t,l corner of map area.
@@ -84,9 +108,9 @@ function MapNav(root, ext, mouse) {
// @pct Change in scale (2 = 2x zoom)
// @fx, @fy zoom focus, [0, 1]
function zoomByPct(pct, fx, fy) {
+ var w = ext.getBounds().width();
_fx = fx;
_fy = fy;
- zoomTween.start(ext.scale(), ext.scale() * pct, 400);
+ zoomTween.start(w, w / pct, 400);
}
-
}
diff --git a/src/gui/mapshaper-map-style.js b/src/gui/mapshaper-map-style.js
index d4afdee01..78e84c00a 100644
--- a/src/gui/mapshaper-map-style.js
+++ b/src/gui/mapshaper-map-style.js
@@ -3,26 +3,33 @@
var MapStyle = (function() {
var darkStroke = "#334",
lightStroke = "#b7d9ea",
- pink = "#f74b80", // dark
- pink2 = "rgba(239, 0, 86, 0.16)", // "#ffd9e7", // medium
+ violet = "#cc6acc",
+ violetFill = "rgba(249, 170, 249, 0.32)",
gold = "#efc100",
black = "black",
selectionFill = "rgba(237, 214, 0, 0.12)",
- hoverFill = "rgba(255, 117, 165, 0.18)",
- outlineStyle = {
+ hoverFill = "rgba(255, 180, 255, 0.2)",
+ activeStyle = { // outline style for the active layer
type: 'outline',
strokeColors: [lightStroke, darkStroke],
strokeWidth: 0.7,
- dotColor: "#223"
+ dotColor: "#223",
+ dotSize: 4
},
- referenceStyle = {
+ activeStyleForLabels = {
+ dotColor: "rgba(250, 0, 250, 0.45)", // violet dot with transparency
+ dotSize: 4
+ },
+ referenceStyle = { // outline style for reference layers
type: 'outline',
strokeColors: [null, '#86c927'],
strokeWidth: 0.85,
- dotColor: "#73ba20"
+ dotColor: "#73ba20",
+ dotSize: 4
},
- highStyle = {
- dotColor: "#F24400"
+ intersectionStyle = {
+ dotColor: "#F24400",
+ dotSize: 4
},
hoverStyles = {
polygon: {
@@ -65,47 +72,50 @@ var MapStyle = (function() {
},
pinnedStyles = {
polygon: {
- fillColor: pink2,
- strokeColor: pink,
- strokeWidth: 1.6
+ fillColor: violetFill,
+ strokeColor: violet,
+ strokeWidth: 1.8
}, point: {
- dotColor: pink,
+ dotColor: 'violet',
dotSize: 7
}, polyline: {
- strokeColor: pink,
+ strokeColor: violet,
strokeWidth: 3
}
};
return {
- getHighlightStyle: function(lyr) {
- var style = utils.extend({}, highStyle);
- var n = internal.countPointsInLayer(lyr);
- style.dotSize = n < 20 && 4 || n < 500 && 3 || 2;
- return style;
+ getIntersectionStyle: function(lyr) {
+ return utils.extend({}, intersectionStyle);
},
getReferenceStyle: function(lyr) {
- var style = utils.extend({}, referenceStyle);
- style.dotSize = calcDotSize(internal.countPointsInLayer(lyr));
+ var style;
+ if (internal.layerHasCanvasDisplayStyle(lyr)) {
+ style = internal.getCanvasDisplayStyle(lyr);
+ } else if (internal.layerHasLabels(lyr)) {
+ style = {dotSize: 0}; // no reference dots if labels are visible
+ } else {
+ style = utils.extend({}, referenceStyle);
+ }
return style;
},
getActiveStyle: function(lyr) {
var style;
- if (internal.layerHasSvgDisplayStyle(lyr)) {
- style = internal.getSvgDisplayStyle(lyr);
+ if (internal.layerHasCanvasDisplayStyle(lyr)) {
+ style = internal.getCanvasDisplayStyle(lyr);
+ } else if (internal.layerHasLabels(lyr)) {
+ style = utils.extend({}, activeStyleForLabels);
} else {
- style = utils.extend({}, outlineStyle);
- style.dotSize = calcDotSize(internal.countPointsInLayer(lyr));
+ style = utils.extend({}, activeStyle);
}
return style;
},
getOverlayStyle: getOverlayStyle
};
- function calcDotSize(n) {
- return n < 20 && 5 || n < 500 && 4 || n < 50000 && 3 || 2;
- }
+ // Returns a display style for the overlay layer. This style displays any
+ // hover or selection affects for the active data layer.
function getOverlayStyle(lyr, o) {
var type = lyr.geometry_type;
var topId = o.id;
@@ -117,25 +127,28 @@ var MapStyle = (function() {
var overlayStyle = {
styler: styler
};
- // first layer: selected feature(s)
- o.selection_ids.forEach(function(i) {
- // skip features in a higher layer
- if (i == topId || o.hover_ids.indexOf(i) > -1) return;
- ids.push(i);
- styles.push(selectionStyles[type]);
- });
+ // first layer: features that were selected via the -inspect command
+ // DISABLED after hit control refactor
+ // o.selection_ids.forEach(function(i) {
+ // // skip features in a higher layer
+ // if (i == topId || o.hover_ids.indexOf(i) > -1) return;
+ // ids.push(i);
+ // styles.push(selectionStyles[type]);
+ // });
// second layer: hover feature(s)
- o.hover_ids.forEach(function(i) {
+ // o.hover_ids.forEach(function(i) {
+ o.ids.forEach(function(i) {
var style;
if (i == topId) return;
- style = o.selection_ids.indexOf(i) > -1 ? selectionHoverStyles[type] : hoverStyles[type];
+ style = hoverStyles[type];
+ // style = o.selection_ids.indexOf(i) > -1 ? selectionHoverStyles[type] : hoverStyles[type];
ids.push(i);
styles.push(style);
});
- // top layer: highlighted feature
+ // top layer: feature that was selected by clicking in inspection mode ([i])
if (topId > -1) {
var isPinned = o.pinned;
- var inSelection = o.selection_ids.indexOf(topId) > -1;
+ var inSelection = false; // o.selection_ids.indexOf(topId) > -1;
var style;
if (isPinned) {
style = pinnedStyles[type];
@@ -148,63 +161,90 @@ var MapStyle = (function() {
styles.push(style);
}
- if (internal.layerHasSvgDisplayStyle(lyr)) {
+ if (internal.layerHasCanvasDisplayStyle(lyr)) {
if (type == 'point') {
- overlayStyle = internal.wrapHoverStyle(internal.getSvgDisplayStyle(lyr), overlayStyle);
+ overlayStyle = internal.wrapOverlayStyle(internal.getCanvasDisplayStyle(lyr), overlayStyle);
}
overlayStyle.type = 'styled';
}
overlayStyle.ids = ids;
+ overlayStyle.overlay = true;
return ids.length > 0 ? overlayStyle : null;
}
-
}());
// Modify style to use scaled circle instead of dot symbol
-internal.wrapHoverStyle = function(style, hoverStyle) {
+internal.wrapOverlayStyle = function(style, hoverStyle) {
var styler = function(obj, i) {
var dotColor;
- style.styler(obj, i);
+ var id = obj.ids ? obj.ids[i] : -1;
+ obj.strokeWidth = 0; // kludge to support setting minimum stroke width
+ style.styler(obj, id);
if (hoverStyle.styler) {
hoverStyle.styler(obj, i);
}
dotColor = obj.dotColor;
if (obj.radius && dotColor) {
- obj.radius += 1.5;
- obj.fillColor = dotColor;
+ obj.radius += 0.4;
+ // delete obj.fillColor; // only show outline
+ obj.fillColor = dotColor; // comment out to only highlight stroke
obj.strokeColor = dotColor;
+ obj.strokeWidth = Math.max(obj.strokeWidth + 0.8, 1.5);
obj.opacity = 1;
}
};
return {styler: styler};
};
-internal.getSvgDisplayStyle = function(lyr) {
- var records = lyr.data.getRecords(),
- fields = internal.getSvgStyleFields(lyr),
- index = internal.svgStyles;
+internal.getCanvasDisplayStyle = function(lyr) {
+ var styleIndex = {
+ opacity: 'opacity',
+ r: 'radius',
+ fill: 'fillColor',
+ stroke: 'strokeColor',
+ 'stroke-width': 'strokeWidth',
+ 'stroke-dasharray': 'lineDash'
+ },
+ // array of field names of relevant svg display properties
+ fields = internal.getCanvasStyleFields(lyr).filter(function(f) {return f in styleIndex;}),
+ records = lyr.data.getRecords();
var styler = function(style, i) {
- var f, key, val;
+ var rec = records[i];
+ var fname, val;
for (var j=0; j 0 && !style.strokeWidth && !style.fillColor && lyr.geometry_type == 'point') {
style.fillColor = 'black';
}
};
return {styler: styler, type: 'styled'};
};
+
+// check if layer should be displayed with styles
+internal.layerHasCanvasDisplayStyle = function(lyr) {
+ var fields = internal.getCanvasStyleFields(lyr);
+ if (lyr.geometry_type == 'point') {
+ return fields.indexOf('r') > -1; // require 'r' field for point symbols
+ }
+ return utils.difference(fields, ['opacity', 'class']).length > 0;
+};
+
+
+internal.getCanvasStyleFields = function(lyr) {
+ var fields = lyr.data ? lyr.data.getFields() : [];
+ return internal.svg.findPropertiesBySymbolGeom(fields, lyr.geometry_type);
+};
diff --git a/src/gui/mapshaper-map-utils.js b/src/gui/mapshaper-map-utils.js
new file mode 100644
index 000000000..d61289a56
--- /dev/null
+++ b/src/gui/mapshaper-map-utils.js
@@ -0,0 +1,19 @@
+
+function getDisplayCoordsById(id, layer, ext) {
+ var coords = getPointCoordsById(id, layer);
+ return ext.translateCoords(coords[0], coords[1]);
+}
+
+function getPointCoordsById(id, layer) {
+ var coords = layer && layer.geometry_type == 'point' && layer.shapes[id];
+ if (!coords || coords.length != 1) {
+ return null;
+ }
+ return coords[0];
+}
+
+function translateDeltaDisplayCoords(dx, dy, ext) {
+ var a = ext.translatePixelCoords(0, 0);
+ var b = ext.translatePixelCoords(dx, dy);
+ return [b[0] - a[0], b[1] - a[1]];
+}
diff --git a/src/gui/mapshaper-map.js b/src/gui/mapshaper-map.js
index def81328c..65250a258 100644
--- a/src/gui/mapshaper-map.js
+++ b/src/gui/mapshaper-map.js
@@ -1,82 +1,69 @@
/* @requires
mapshaper-gui-lib
-mapshaper-maplayer
+mapshaper-maplayer2
mapshaper-map-nav
mapshaper-map-extent
-mapshaper-hit-control
-mapshaper-inspection-control
mapshaper-map-style
+mapshaper-svg-display
+mapshaper-layer-stack
+mapshaper-layer-sorting
+mapshaper-gui-proxy
+mapshaper-coordinates-display
+mapshaper-hit-control2
+mapshaper-inspection-control2
+mapshaper-symbol-dragging2
*/
+utils.inherit(MshpMap, EventDispatcher);
-// Test if map should be re-framed to show updated layer
-gui.mapNeedsReset = function(newBounds, prevBounds, mapBounds) {
- if (!prevBounds) return true;
- if (prevBounds.xmin === 0 || newBounds.xmin === 0) return true; // kludge to handle tables
- var viewportPct = gui.getIntersectionPct(newBounds, mapBounds);
- var contentPct = gui.getIntersectionPct(mapBounds, newBounds);
- var boundsChanged = !prevBounds.equals(newBounds);
- var inView = newBounds.intersects(mapBounds);
- var areaChg = newBounds.area() / prevBounds.area();
- if (!boundsChanged) return false; // don't reset if layer extent hasn't changed
- if (!inView) return true; // reset if layer is out-of-view
- if (viewportPct < 0.3 && contentPct < 0.9) return true; // reset if content is mostly offscreen
- if (areaChg > 1e8 || areaChg < 1e-8) return true; // large area chg, e.g. after projection
- return false;
-};
+function MshpMap(gui) {
+ var opts = gui.options,
+ el = gui.container.findChild('.map-layers').node(),
+ position = new ElementPosition(el),
+ model = gui.model,
+ map = this,
+ _mouse = new MouseArea(el, position),
+ _ext = new MapExtent(position),
+ // _hit = new HitControl(gui, _ext, _mouse),
+ _hit = new HitControl2(gui, _ext, _mouse),
+ _visibleLayers = [], // cached visible map layers
+ _fullBounds = null,
+ _intersectionLyr, _activeLyr, _overlayLyr,
+ _inspector, _stack, _nav, _editor,
+ _dynamicCRS;
-// TODO: move to utilities file
-gui.getBoundsIntersection = function(a, b) {
- var c = new Bounds();
- if (a.intersects(b)) {
- c.setBounds(Math.max(a.xmin, b.xmin), Math.max(a.ymin, b.ymin),
- Math.min(a.xmax, b.xmax), Math.min(a.ymax, b.ymax));
+ _nav = new MapNav(gui, _ext, _mouse);
+ if (gui.options.showMouseCoordinates) {
+ new CoordinatesDisplay(gui, _ext, _mouse);
}
- return c;
-};
-
-// Returns proportion of bb2 occupied by bb1
-gui.getIntersectionPct = function(bb1, bb2) {
- return gui.getBoundsIntersection(bb1, bb2).area() / bb2.area() || 0;
-};
+ _mouse.disable(); // wait for gui.focus() to activate mouse events
+ model.on('select', function(e) {
+ _intersectionLyr = null;
+ _overlayLyr = null;
+ });
-function MshpMap(model) {
- var _root = El('#mshp-main-map'),
- _layers = El('#map-layers'),
- _ext = new MapExtent(_layers),
- _mouse = new MouseArea(_layers.node()),
- _nav = new MapNav(_root, _ext, _mouse),
- _inspector = new InspectionControl(model, new HitControl(_ext, _mouse));
-
- var _referenceCanv = new DisplayCanvas().appendTo(_layers), // comparison layer
- _activeCanv = new DisplayCanvas().appendTo(_layers), // data layer shapes
- _overlayCanv = new DisplayCanvas().appendTo(_layers), // hover and selection shapes
- _annotationCanv = new DisplayCanvas().appendTo(_layers), // used for line intersections
- _annotationLyr, _annotationStyle,
- _referenceLyr, _referenceStyle,
- _activeLyr, _activeStyle, _overlayStyle;
-
- _ext.on('change', drawLayers);
-
- _inspector.on('change', function(e) {
- var lyr = _activeLyr.getDisplayLayer().layer;
- _overlayStyle = MapStyle.getOverlayStyle(lyr, e);
- drawLayer(_activeLyr, _overlayCanv, _overlayStyle);
+ gui.on('active', function() {
+ _mouse.enable();
});
- model.on('select', function(e) {
- _annotationStyle = null;
- _overlayStyle = null;
+ gui.on('inactive', function() {
+ _mouse.disable();
});
+ // Refresh map display in response to data changes, layer selection, etc.
model.on('update', function(e) {
- var prevBounds = _activeLyr ?_activeLyr.getBounds() : null,
- needReset = false;
+ var prevLyr = _activeLyr || null;
+ var fullBounds;
+ var needReset;
+
+ if (!prevLyr) {
+ initMap(); // init map extent, resize events, etc. on first call
+ }
+
if (arcsMayHaveChanged(e.flags)) {
- // regenerate filtered arcs when simplification thresholds are calculated
- // or arcs are updated
- delete e.dataset.filteredArcs;
+ // regenerate filtered arcs the next time they are needed for rendering
+ delete e.dataset.displayArcs;
// reset simplification after projection (thresholds have changed)
// TODO: preserve simplification pct (need to record pct before change)
@@ -85,106 +72,406 @@ function MshpMap(model) {
}
}
- _activeLyr = initActiveLayer(e);
- needReset = gui.mapNeedsReset(_activeLyr.getBounds(), prevBounds, _ext.getBounds());
- _ext.setBounds(_activeLyr.getBounds()); // update map extent to match bounds of active group
- if (needReset) {
- // zoom to full view of the active layer and redraw
- _ext.reset(true);
- } else {
- // refresh without navigating
+ if (e.flags.simplify_method) { // no redraw needed
+ return false;
+ }
+
+ if (e.flags.simplify_amount || e.flags.redraw_only) { // only redraw (slider drag)
drawLayers();
+ return;
}
- });
- this.setReferenceLayer = function(lyr, dataset) {
- if (lyr) {
- _referenceLyr = new DisplayLayer(lyr, dataset, _ext);
- _referenceStyle = MapStyle.getReferenceStyle(lyr);
- } else if (_referenceLyr) {
- _referenceStyle = null;
- _referenceLyr = null;
+ _activeLyr = getMapLayer(e.layer, e.dataset, getDisplayOptions());
+ _activeLyr.style = MapStyle.getActiveStyle(_activeLyr.layer);
+ _activeLyr.active = true;
+ // if (_inspector) _inspector.updateLayer(_activeLyr);
+ _hit.setLayer(_activeLyr);
+ updateVisibleMapLayers();
+ fullBounds = getFullBounds();
+ if (!prevLyr || !_fullBounds || prevLyr.tabular || _activeLyr.tabular || isFrameView()) {
+ needReset = true;
+ } else {
+ needReset = GUI.mapNeedsReset(fullBounds, _fullBounds, _ext.getBounds());
}
- drawLayers(); // draw all layers (reference layer can change how active layer is drawn)
- };
+
+ if (isFrameView()) {
+ _nav.setZoomFactor(0.05); // slow zooming way down to allow fine-tuning frame placement // 0.03
+ _ext.setFrame(getFullBounds()); // TODO: remove redundancy with drawLayers()
+ needReset = true; // snap to frame extent
+ } else {
+ _nav.setZoomFactor(1);
+ }
+ _ext.setBounds(fullBounds); // update 'home' button extent
+ _fullBounds = fullBounds;
+ if (needReset) {
+ _ext.reset();
+ }
+ drawLayers();
+ map.dispatchEvent('updated');
+ });
// Currently used to show dots at line intersections
- this.setHighlightLayer = function(lyr, dataset) {
+ this.setIntersectionLayer = function(lyr, dataset) {
if (lyr) {
- _annotationLyr = new DisplayLayer(lyr, dataset, _ext);
- _annotationStyle = MapStyle.getHighlightStyle(lyr);
- drawLayer(_annotationLyr, _annotationCanv, _annotationStyle);
+ _intersectionLyr = getMapLayer(lyr, dataset, getDisplayOptions());
+ _intersectionLyr.style = MapStyle.getIntersectionStyle(_intersectionLyr.layer);
} else {
- _annotationStyle = null;
- _annotationLyr = null;
+ _intersectionLyr = null;
}
+ _stack.drawOverlay2Layer(_intersectionLyr); // also hides
};
- // lightweight way to update simplification of display lines
- // TODO: consider handling this as a model update
- this.setSimplifyPct = function(pct) {
- _activeLyr.setRetainedPct(pct);
+ this.setInteractivity = function(toOn) {
+
+ };
+
+ this.setLayerVisibility = function(target, isVisible) {
+ var lyr = target.layer;
+ lyr.visibility = isVisible ? 'visible' : 'hidden';
+ // if (_inspector && isActiveLayer(lyr)) {
+ // _inspector.updateLayer(isVisible ? _activeLyr : null);
+ // }
+ if (isActiveLayer(lyr)) {
+ _hit.setLayer(isVisible ? _activeLyr : null);
+ }
+ };
+
+ this.getCenterLngLat = function() {
+ var bounds = _ext.getBounds();
+ var crs = this.getDisplayCRS();
+ // TODO: handle case where active layer is a frame layer
+ if (!bounds.hasBounds() || !crs) {
+ return null;
+ }
+ return internal.toLngLat([bounds.centerX(), bounds.centerY()], crs);
+ };
+
+ this.getDisplayCRS = function() {
+ var crs;
+ if (_activeLyr && _activeLyr.geographic) {
+ crs = _activeLyr.dynamic_crs || internal.getDatasetCRS(_activeLyr.source.dataset);
+ }
+ return crs || null;
+ };
+
+ this.getExtent = function() {return _ext;};
+ this.isActiveLayer = isActiveLayer;
+ this.isVisibleLayer = isVisibleLayer;
+
+ // called by layer menu after layer visibility is updated
+ this.redraw = function() {
+ updateVisibleMapLayers();
drawLayers();
};
- function referenceLayerVisible() {
- if (!_referenceLyr ||
- // don't show if same as active layer
- _activeLyr && _activeLyr.getLayer() == _referenceLyr.getLayer() ||
- // or if active layer isn't geographic (kludge)
- _activeLyr && !_activeLyr.getLayer().shapes) {
- return false;
+ // Set or clear a CRS to use for display, without reprojecting the underlying dataset(s).
+ // crs: a CRS object or string, or null to clear the current setting
+ this.setDisplayCRS = function(crs) {
+ // TODO: update bounds of frame layer, if there is a frame layer
+ var oldCRS = this.getDisplayCRS();
+ var newCRS = utils.isString(crs) ? internal.getCRS(crs) : crs;
+ // TODO: handle case that old and new CRS are the same
+ _dynamicCRS = newCRS;
+ // clear any stored FilteredArcs objects (so they will be recreated with the desired projection)
+ gui.model.getDatasets().forEach(function(dataset) {
+ delete dataset.displayArcs;
+ });
+
+ // Reproject all visible map layers
+ if (_activeLyr) _activeLyr = projectDisplayLayer(_activeLyr, newCRS);
+ if (_intersectionLyr) _intersectionLyr = projectDisplayLayer(_intersectionLyr, newCRS);
+ if (_overlayLyr) {
+ _overlayLyr = projectDisplayLayer(_overlayLyr, newCRS);
+ }
+ updateVisibleMapLayers(); // any other display layers will be projected as they are regenerated
+
+ // Update map extent (also triggers redraw)
+ projectMapExtent(_ext, oldCRS, this.getDisplayCRS(), getFullBounds());
+ };
+
+ function initMap() {
+ _ext.resize();
+ _stack = new LayerStack(gui, el, _ext, _mouse);
+ gui.buttons.show();
+
+ _ext.on('change', function(e) {
+ if (e.reset) return; // don't need to redraw map here if extent has been reset
+ if (isFrameView()) {
+ updateFrameExtent();
+ }
+ drawLayers(true);
+ });
+
+ if (opts.inspectorControl) {
+ _inspector = new InspectionControl2(gui, _hit);
+ _inspector.on('data_change', function(e) {
+ // refresh the display if a style variable has been changed interactively
+ if (internal.isSupportedSvgProperty(e.field)) {
+ drawLayers();
+ }
+ });
}
- return true;
+
+ _hit.on('change', function(e) {
+ // draw highlight effect for hover and select
+ _overlayLyr = getMapLayerOverlay(_activeLyr, e);
+ _stack.drawOverlayLayer(_overlayLyr);
+ });
+ _editor = new SymbolDragging2(gui, _ext, _hit);
+ _editor.on('location_change', function(e) {
+ // TODO: optimize redrawing
+ drawLayers();
+ });
+
+ gui.on('resize', function() {
+ position.update(); // kludge to detect new map size after console toggle
+ });
}
- function initActiveLayer(o) {
- var lyr = new DisplayLayer(o.layer, o.dataset, _ext);
- _inspector.updateLayer(lyr);
- _activeStyle = MapStyle.getActiveStyle(lyr.getDisplayLayer().layer);
- return lyr;
+ function getDisplayOptions() {
+ return {
+ crs: _dynamicCRS
+ };
}
// Test if an update may have affected the visible shape of arcs
// @flags Flags from update event
function arcsMayHaveChanged(flags) {
- return flags.presimplify || flags.simplify || flags.proj || flags.arc_count ||
- flags.repair || flags.clip || flags.erase || flags.slice || flags.affine || false;
+ return flags.simplify_method || flags.simplify || flags.proj ||
+ flags.arc_count || flags.repair || flags.clip || flags.erase ||
+ flags.slice || flags.affine || flags.rectangle || false;
}
- function referenceStyle() {
- return referenceLayerVisible() ? _referenceStyle : null;
+ // Update map frame after user navigates the map in frame edit mode
+ function updateFrameExtent() {
+ var frameLyr = internal.findFrameLayer(model);
+ var rec = frameLyr.data.getRecordAt(0);
+ var viewBounds = _ext.getBounds();
+ var w = viewBounds.width() * rec.width / _ext.width();
+ var h = w * rec.height / rec.width;
+ var cx = viewBounds.centerX();
+ var cy = viewBounds.centerY();
+ rec.bbox = [cx - w/2, cy - h/2, cx + w/2, cy + h/2];
+ _ext.setFrame(getFrameData());
+ _ext.setBounds(new Bounds(rec.bbox));
+ _ext.reset();
}
- function activeStyle() {
- var style = _activeStyle;
- if (referenceLayerVisible() && _activeStyle.type != 'styled') {
- style = utils.defaults({
- // kludge to hide ghosted layers
- strokeColors: [null, _activeStyle.strokeColors[1]]
- }, _activeStyle);
+ function getFullBounds() {
+ var b = new Bounds();
+ var marginPct = 0.025;
+ var pad = 1e-4;
+ if (isPreviewView()) {
+ return internal.getFrameLayerBounds(internal.findFrameLayer(model));
+ }
+ getDrawableContentLayers().forEach(function(lyr) {
+ b.mergeBounds(lyr.bounds);
+ if (isTableView()) {
+ marginPct = getTableMargin(lyr.layer);
+ }
+ });
+ if (!b.hasBounds()) {
+ // assign bounds to empty layers, to prevent rendering errors downstream
+ b.setBounds(0,0,0,0);
}
- return style;
+ // Inflate display bounding box by a tiny amount (gives extent to single-point layers and collapsed shapes)
+ b.padBounds(pad,pad,pad,pad);
+ // add margin
+ b.scale(1 + marginPct * 2);
+ return b;
}
- function drawLayers() {
- // TODO: consider drawing active and reference layers to the same canvas
- drawLayer(_referenceLyr, _referenceCanv, referenceStyle());
- drawLayer(_activeLyr, _overlayCanv, _overlayStyle);
- drawLayer(_activeLyr, _activeCanv, activeStyle());
- drawLayer(_annotationLyr, _annotationCanv, _annotationStyle);
+ // Calculate margin when displaying content at full zoom, as pct of screen size
+ function getTableMargin(lyr) {
+ var n = internal.getFeatureCount(lyr);
+ var pct = 0.04;
+ if (n < 5) {
+ pct = 0.2;
+ } else if (n < 100) {
+ pct = 0.1;
+ }
+ return pct;
}
- function drawLayer(lyr, canv, style) {
- if (style) {
- canv.prep(_ext);
- lyr.draw(canv, style);
- } else {
- canv.hide();
+ function isActiveLayer(lyr) {
+ return _activeLyr && lyr == _activeLyr.source.layer || false;
+ }
+
+ function isVisibleLayer(lyr) {
+ if (isActiveLayer(lyr)) {
+ return lyr.visibility != 'hidden';
+ }
+ return lyr.visibility == 'visible';
+ }
+
+ function isVisibleDataLayer(lyr) {
+ return isVisibleLayer(lyr) && !internal.isFurnitureLayer(lyr);
+ }
+
+ function isFrameLayer(lyr) {
+ return !!(lyr && lyr == internal.findFrameLayer(model));
+ }
+
+ function isTableView() {
+ return !isPreviewView() && !!_activeLyr.tabular;
+ }
+
+ function isPreviewView() {
+ var frameLyr = internal.findFrameLayer(model);
+ return !!frameLyr; // && isVisibleLayer(frameLyr)
+ }
+
+ // Frame view means frame layer is visible and active (selected)
+ function isFrameView() {
+ var frameLyr = internal.findFrameLayer(model);
+ return isActiveLayer(frameLyr) && isVisibleLayer(frameLyr);
+ }
+
+ function getFrameData() {
+ var frameLyr = internal.findFrameLayer(model);
+ return frameLyr && internal.getFurnitureLayerData(frameLyr) || null;
+ }
+
+ function updateVisibleMapLayers() {
+ var layers = [];
+ model.getLayers().forEach(function(o) {
+ if (!isVisibleLayer(o.layer)) return;
+ if (isActiveLayer(o.layer)) {
+ layers.push(_activeLyr);
+ } else if (!isTableView()) {
+ layers.push(getMapLayer(o.layer, o.dataset, getDisplayOptions()));
+ }
+ });
+ _visibleLayers = layers;
+ }
+
+ function getVisibleMapLayers() {
+ return _visibleLayers;
+ }
+
+ function findActiveLayer(layers) {
+ return layers.filter(function(o) {
+ return o == _activeLyr;
+ });
+ }
+
+ function getDrawableContentLayers() {
+ var layers = getVisibleMapLayers();
+ if (isTableView()) return findActiveLayer(layers);
+ return layers.filter(function(o) {
+ return !!o.geographic;
+ });
+ }
+
+ function getDrawableFurnitureLayers(layers) {
+ if (!isPreviewView()) return [];
+ return getVisibleMapLayers().filter(function(o) {
+ return internal.isFurnitureLayer(o);
+ });
+ }
+
+ function updateLayerStyles(layers) {
+ layers.forEach(function(mapLayer, i) {
+ if (mapLayer.active) {
+ // style is already assigned
+ if (mapLayer.style.type != 'styled' && layers.length > 1 && mapLayer.style.strokeColors) {
+ // kludge to hide ghosted layers when reference layers are present
+ // TODO: consider never showing ghosted layers (which appear after
+ // commands like dissolve and filter).
+ mapLayer.style = utils.defaults({
+ strokeColors: [null, mapLayer.style.strokeColors[1]]
+ }, mapLayer.style);
+ }
+ } else {
+ if (mapLayer.layer == _activeLyr.layer) {
+ console.error("Error: shared map layer");
+ }
+ mapLayer.style = MapStyle.getReferenceStyle(mapLayer.layer);
+ }
+ });
+ }
+
+ function sortMapLayers(layers) {
+ layers.sort(function(a, b) {
+ // assume that each layer has a stack_id (assigned by updateLayerStackOrder())
+ return a.source.layer.stack_id - b.source.layer.stack_id;
+ });
+ }
+
+ // onlyNav (bool): only map extent has changed, symbols are unchanged
+ function drawLayers(onlyNav) {
+ var contentLayers = getDrawableContentLayers();
+ var furnitureLayers = getDrawableFurnitureLayers();
+ if (!(_ext.width() > 0 && _ext.height() > 0)) {
+ // TODO: track down source of these errors
+ console.error("[drawLayers()] Collapsed map container, unable to draw.");
+ return;
}
+ if (!onlyNav) {
+ // kludge to handle layer visibility toggling
+ _ext.setFrame(isPreviewView() ? getFrameData() : null);
+ _ext.setBounds(getFullBounds());
+ updateLayerStyles(contentLayers);
+ // update stack_id property of all layers
+ internal.updateLayerStackOrder(model.getLayers());
+ }
+ sortMapLayers(contentLayers);
+ _stack.drawContentLayers(contentLayers, onlyNav);
+ // draw intersection dots
+ _stack.drawOverlay2Layer(_intersectionLyr);
+ // draw hover & selection effects
+ _stack.drawOverlayLayer(_overlayLyr);
+ // _stack.drawFurnitureLayers(furnitureLayers, onlyNav);
+ _stack.drawFurnitureLayers(furnitureLayers); // re-render on nav, because scalebars
+ }
+}
+function getMapLayerOverlay(obj, e) {
+ var style = MapStyle.getOverlayStyle(obj.layer, e);
+ if (!style) return null;
+ return utils.defaults({
+ layer: filterLayerByIds(obj.layer, style.ids),
+ style: style
+ }, obj);
+}
+
+function filterLayerByIds(lyr, ids) {
+ var shapes;
+ if (lyr.shapes) {
+ shapes = ids.map(function(id) {
+ return lyr.shapes[id];
+ });
+ return utils.defaults({shapes: shapes}, lyr);
}
+ return lyr;
}
-utils.inherit(MshpMap, EventDispatcher);
+// Test if map should be re-framed to show updated layer
+GUI.mapNeedsReset = function(newBounds, prevBounds, mapBounds) {
+ var viewportPct = GUI.getIntersectionPct(newBounds, mapBounds);
+ var contentPct = GUI.getIntersectionPct(mapBounds, newBounds);
+ var boundsChanged = !prevBounds.equals(newBounds);
+ var inView = newBounds.intersects(mapBounds);
+ var areaChg = newBounds.area() / prevBounds.area();
+ if (!boundsChanged) return false; // don't reset if layer extent hasn't changed
+ if (!inView) return true; // reset if layer is out-of-view
+ if (viewportPct < 0.3 && contentPct < 0.9) return true; // reset if content is mostly offscreen
+ if (areaChg > 1e8 || areaChg < 1e-8) return true; // large area chg, e.g. after projection
+ return false;
+};
+
+// TODO: move to utilities file
+GUI.getBoundsIntersection = function(a, b) {
+ var c = new Bounds();
+ if (a.intersects(b)) {
+ c.setBounds(Math.max(a.xmin, b.xmin), Math.max(a.ymin, b.ymin),
+ Math.min(a.xmax, b.xmax), Math.min(a.ymax, b.ymax));
+ }
+ return c;
+};
+
+// Returns proportion of bb2 occupied by bb1
+GUI.getIntersectionPct = function(bb1, bb2) {
+ return GUI.getBoundsIntersection(bb1, bb2).area() / bb2.area() || 0;
+};
diff --git a/src/gui/mapshaper-maplayer.js b/src/gui/mapshaper-maplayer.js
deleted file mode 100644
index 2bd87b83c..000000000
--- a/src/gui/mapshaper-maplayer.js
+++ /dev/null
@@ -1,177 +0,0 @@
-/* @requires mapshaper-canvas, mapshaper-gui-shapes, mapshaper-gui-table */
-
-function DisplayLayer(lyr, dataset, ext) {
- var _displayBounds;
- var _arcFlags;
-
- this.getLayer = function() {return lyr;};
-
- this.getBounds = function() {
- return _displayBounds;
- };
-
- this.setRetainedPct = function(pct) {
- var arcs = dataset.filteredArcs || dataset.arcs;
- if (arcs) {
- arcs.setRetainedPct(pct);
- }
- };
-
- // @ext map extent
- this.getDisplayLayer = function() {
- var arcs = lyr.display.arcs,
- layer = lyr.display.layer || lyr;
- if (!arcs) {
- // use filtered arcs if available & map extent is known
- arcs = dataset.filteredArcs ?
- dataset.filteredArcs.getArcCollection(ext) : dataset.arcs;
- }
- return {
- layer: layer,
- dataset: {arcs: arcs},
- geographic: layer == lyr // false if using table-only shapes
- };
- };
-
- this.draw = function(canv, style) {
- if (style.type == 'outline') {
- this.drawStructure(canv, style);
- } else {
- this.drawShapes(canv, style);
- }
- };
-
- this.drawStructure = function(canv, style) {
- var obj = this.getDisplayLayer(ext);
- var arcs = obj.dataset.arcs;
- var darkStyle = {strokeWidth: style.strokeWidth, strokeColor: style.strokeColors[1]},
- lightStyle = {strokeWidth: style.strokeWidth, strokeColor: style.strokeColors[0]};
- var filter;
-
- if (arcs && _arcFlags) {
- if (lightStyle.strokeColor) {
- filter = getArcFilter(arcs, ext, _arcFlags, 0);
- canv.drawArcs(arcs, lightStyle, filter);
- }
- if (darkStyle.strokeColor) {
- filter = getArcFilter(arcs, ext, _arcFlags, 1);
- canv.drawArcs(arcs, darkStyle, filter);
- }
- }
- if (obj.layer.geometry_type == 'point') {
- canv.drawSquareDots(obj.layer.shapes, style);
- }
- };
-
- this.drawShapes = function(canv, style) {
- // TODO: add filter for out-of-view shapes
- var obj = this.getDisplayLayer(ext);
- var lyr = style.ids ? filterLayer(obj.layer, style.ids) : obj.layer;
- if (lyr.geometry_type == 'point') {
- if (style.type == 'styled') {
- canv.drawPoints(lyr.shapes, style);
- } else {
- canv.drawSquareDots(lyr.shapes, style);
- }
- } else {
- canv.drawPathShapes(lyr.shapes, obj.dataset.arcs, style);
- }
- };
-
- function getArcFilter(arcs, ext, flags, flag) {
- var minPathLen = 0.5 * ext.getPixelSize(),
- geoBounds = ext.getBounds(),
- geoBBox = geoBounds.toArray(),
- allIn = geoBounds.contains(arcs.getBounds()),
- visible;
- // don't continue dropping paths if user zooms out farther than full extent
- if (ext.scale() < 1) minPathLen *= ext.scale();
- return function(i) {
- var visible = true;
- if (flags[i] != flag) {
- visible = false;
- } else if (arcs.arcIsSmaller(i, minPathLen)) {
- visible = false;
- } else if (!allIn && !arcs.arcIntersectsBBox(i, geoBBox)) {
- visible = false;
- }
- return visible;
- };
- }
-
- function filterLayer(lyr, ids) {
- if (lyr.shapes) {
- shapes = ids.map(function(id) {
- return lyr.shapes[id];
- });
- return utils.defaults({shapes: shapes}, lyr);
- }
- return lyr;
- }
-
- function initArcFlags(self) {
- var o = self.getDisplayLayer();
- if (o.dataset.arcs && internal.layerHasPaths(o.layer)) {
- _arcFlags = new Uint8Array(o.dataset.arcs.size());
- // Arcs belonging to at least one path are flagged 1, others 0
- internal.countArcsInShapes(o.layer.shapes, _arcFlags);
- for (var i=0, n=_arcFlags.length; i 0 || !arcBounds.hasBounds()) {
- bounds = lyrBounds;
- } else {
- // if a point layer has no extent (e.g. contains only a single point),
- // then merge with arc bounds, to place the point in context.
- bounds = arcBounds.mergeBounds(lyrBounds);
- }
- }
- }
-
- // If a layer has collapsed, inflate it by a default amount
- if (bounds.width() === 0) {
- bounds.xmin = (bounds.centerX() || 0) - 1;
- bounds.xmax = bounds.xmin + 2;
- }
- if (bounds.height() === 0) {
- bounds.ymin = (bounds.centerY() || 0) - 1;
- bounds.ymax = bounds.ymin + 2;
- }
- return bounds;
-}
diff --git a/src/gui/mapshaper-maplayer2.js b/src/gui/mapshaper-maplayer2.js
new file mode 100644
index 000000000..1f3e9ddc9
--- /dev/null
+++ b/src/gui/mapshaper-maplayer2.js
@@ -0,0 +1,89 @@
+/* @requires mapshaper-canvas, mapshaper-gui-shapes, mapshaper-gui-table, mapshaper-dynamic-crs */
+
+// Wrap a layer in an object along with information needed for rendering
+function getMapLayer(layer, dataset, opts) {
+ var obj = {
+ layer: null,
+ arcs: null,
+ // display_arcs: null,
+ style: null,
+ source: {
+ layer: layer,
+ dataset: dataset
+ },
+ empty: internal.getFeatureCount(layer) === 0
+ };
+
+ var sourceCRS = opts.crs && internal.getDatasetCRS(dataset); // get src iff display CRS is given
+ var displayCRS = opts.crs || null;
+ var arcs = dataset.arcs;
+
+ // Assume that dataset.displayArcs is in the display CRS
+ // (it should have been deleted upstream if reprojected is needed)
+ if (arcs && !dataset.displayArcs) {
+ // project arcs, if needed
+ if (needReprojectionForDisplay(sourceCRS, displayCRS)) {
+ arcs = projectArcsForDisplay(arcs, sourceCRS, displayCRS);
+ }
+
+ // init filtered arcs, if needed
+ dataset.displayArcs = new FilteredArcCollection(arcs);
+ }
+
+ if (internal.layerHasFurniture(layer)) {
+ obj.furniture = true;
+ obj.furniture_type = internal.getFurnitureLayerType(layer);
+ obj.layer = layer;
+ // treating furniture layers (other than frame) as tabular for now,
+ // so there is something to show if they are selected
+ obj.tabular = obj.furniture_type != 'frame';
+ } else if (obj.empty) {
+ obj.layer = {shapes: []}; // ideally we should avoid empty layers
+ } else if (!layer.geometry_type) {
+ obj.tabular = true;
+ } else {
+ obj.geographic = true;
+ obj.layer = layer;
+ obj.arcs = arcs; // replaced by filtered arcs during render sequence
+ }
+
+ if (obj.tabular) {
+ utils.extend(obj, getDisplayLayerForTable(layer.data));
+ }
+
+ // dynamic reprojection (arcs were already reprojected above)
+ if (obj.geographic && needReprojectionForDisplay(sourceCRS, displayCRS)) {
+ obj.dynamic_crs = displayCRS;
+ if (internal.layerHasPoints(layer)) {
+ obj.layer = projectPointsForDisplay(layer, sourceCRS, displayCRS);
+ }
+ }
+
+ obj.bounds = getDisplayBounds(obj.layer, obj.arcs);
+ return obj;
+}
+
+
+function getDisplayBounds(lyr, arcs) {
+ var arcBounds = arcs ? arcs.getBounds() : new Bounds(),
+ bounds = arcBounds, // default display extent: all arcs in the dataset
+ lyrBounds;
+
+ if (lyr.geometry_type == 'point') {
+ lyrBounds = internal.getLayerBounds(lyr);
+ if (lyrBounds && lyrBounds.hasBounds()) {
+ if (lyrBounds.area() > 0 || !arcBounds.hasBounds()) {
+ bounds = lyrBounds;
+ } else {
+ // if a point layer has no extent (e.g. contains only a single point),
+ // then merge with arc bounds, to place the point in context.
+ bounds = arcBounds.mergeBounds(lyrBounds);
+ }
+ }
+ }
+
+ if (!bounds || !bounds.hasBounds()) { // empty layer
+ bounds = new Bounds();
+ }
+ return bounds;
+}
diff --git a/src/gui/mapshaper-mode-button.js b/src/gui/mapshaper-mode-button.js
index 98fcce9b2..d81e4b867 100644
--- a/src/gui/mapshaper-mode-button.js
+++ b/src/gui/mapshaper-mode-button.js
@@ -1,9 +1,9 @@
/* @requires mapshaper-gui-lib */
-function ModeButton(el, name) {
+function ModeButton(modes, el, name) {
var btn = El(el),
active = false;
- gui.on('mode', function(e) {
+ modes.on('mode', function(e) {
active = e.name == name;
if (active) {
btn.addClass('active');
@@ -13,6 +13,6 @@ function ModeButton(el, name) {
});
btn.on('click', function() {
- gui.enterMode(active ? null : name);
+ modes.enterMode(active ? null : name);
});
}
diff --git a/src/gui/mapshaper-popup.js b/src/gui/mapshaper-popup.js
index 0c8011869..6a8871549 100644
--- a/src/gui/mapshaper-popup.js
+++ b/src/gui/mapshaper-popup.js
@@ -1,37 +1,64 @@
/* @requires mapshaper-gui-lib */
-
-function Popup() {
- var parent = El('#mshp-main-map');
+// @onNext: handler for switching between multiple records
+function Popup(gui, onNext, onPrev) {
+ var self = new EventDispatcher();
+ var parent = gui.container.findChild('.mshp-main-map');
var el = El('div').addClass('popup').appendTo(parent).hide();
- // var head = El('div').addClass('popup-head').appendTo(el).text('Feature 1 of 5 next prev');
var content = El('div').addClass('popup-content').appendTo(el);
+ // multi-hit display and navigation
+ var tab = El('div').addClass('popup-tab').appendTo(el).hide();
+ var nav = El('div').addClass('popup-nav').appendTo(tab);
+ var prevLink = El('span').addClass('popup-nav-arrow colored-text').appendTo(nav).text('◀');
+ var navInfo = El('span').addClass('popup-nav-info').appendTo(nav);
+ var nextLink = El('span').addClass('popup-nav-arrow colored-text').appendTo(nav).text('▶');
+
+ nextLink.on('click', onNext);
+ prevLink.on('click', onPrev);
- this.show = function(rec, table, editable) {
+ self.show = function(id, ids, table, pinned, editable) {
+ var rec = table ? (editable ? table.getRecordAt(id) : table.getReadOnlyRecordAt(id)) : {};
var maxHeight = parent.node().clientHeight - 36;
- this.hide(); // clean up if panel is already open
+ self.hide(); // clean up if panel is already open
render(content, rec, table, editable);
+ if (ids && ids.length > 1) {
+ showNav(id, ids, pinned);
+ } else {
+ tab.hide();
+ }
el.show();
if (content.node().clientHeight > maxHeight) {
content.css('height:' + maxHeight + 'px');
}
};
- this.hide = function() {
+ self.hide = function() {
// make sure any pending edits are made before re-rendering popup
// TODO: only blur popup fields
- gui.blurActiveElement();
+ GUI.blurActiveElement();
content.empty();
content.node().removeAttribute('style'); // remove inline height
el.hide();
};
+ return self;
+
+ function showNav(id, ids, pinned) {
+ var num = ids.indexOf(id) + 1;
+ navInfo.text(' ' + num + ' / ' + ids.length + ' ');
+ nextLink.css('display', pinned ? 'inline-block' : 'none');
+ prevLink.css('display', pinned && ids.length > 2 ? 'inline-block' : 'none');
+ tab.show();
+ }
+
function render(el, rec, table, editable) {
var tableEl = El('table').addClass('selectable'),
rows = 0;
utils.forEachProperty(rec, function(v, k) {
- var type = internal.getFieldType(v, k, table);
+ var type;
+ // missing GeoJSON fields are set to undefined on import; skip these
if (v !== undefined) {
+ type = internal.getFieldType(v, k, table);
renderRow(tableEl, rec, k, type, editable);
rows++;
}
@@ -39,7 +66,11 @@ function Popup() {
if (rows > 0) {
tableEl.appendTo(el);
} else {
- el.html('This layer is missing attribute data.
');
+ // Some individual features can have undefined values for some or all of
+ // their data properties (properties are set to undefined when an input JSON file
+ // has inconsistent fields, or after force-merging layers with inconsistent fields).
+ el.html(utils.format('This %s is missing attribute data.
',
+ table && table.getFields().length > 0 ? 'feature': 'layer'));
}
}
@@ -82,11 +113,12 @@ function Popup() {
// invalid value; revert to previous value
input.value(strval);
} else {
- // field content has changed;
+ // field content has changed
strval = strval2;
rec[key] = val2;
input.value(strval);
setFieldClass(el, val2, type);
+ self.dispatchEvent('update', {field: key, value: val2});
}
});
}
@@ -143,5 +175,5 @@ internal.getInputParser = function(type) {
internal.getFieldType = function(val, key, table) {
// if a field has a null value, look at entire column to identify type
- return internal.getValueType(val) || internal.getColumnType(key, table);
+ return internal.getValueType(val) || internal.getColumnType(key, table.getRecords());
};
diff --git a/src/gui/mapshaper-progress-bar.js b/src/gui/mapshaper-progress-bar.js
deleted file mode 100644
index fed39a077..000000000
--- a/src/gui/mapshaper-progress-bar.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/* @requires mapshaper-gui-lib */
-
-function ProgressBar(el) {
- var size = 80,
- posCol = '#285d7e',
- negCol = '#bdced6',
- outerRadius = size / 2,
- innerRadius = outerRadius / 2,
- cx = outerRadius,
- cy = outerRadius,
- bar = El('div').addClass('progress-bar'),
- canv = El('canvas').appendTo(bar).node(),
- ctx = canv.getContext('2d'),
- msg = El('div').appendTo(bar);
-
- canv.width = size;
- canv.height = size;
-
- this.appendTo = function(el) {
- bar.appendTo(el);
- };
-
- this.update = function(pct, str) {
- var twoPI = Math.PI * 2;
- ctx.clearRect(0, 0, size, size);
- if (pct > 0) {
- drawCircle(negCol, outerRadius, twoPI);
- drawCircle(posCol, outerRadius, twoPI * pct);
- drawCircle(null, innerRadius, twoPI);
- }
- msg.html(str || '');
- };
-
- this.remove = function() {
- bar.remove();
- };
-
- function drawCircle(color, radius, radians) {
- var halfPI = Math.PI / 2;
- if (!color) ctx.globalCompositeOperation = 'destination-out';
- ctx.fillStyle = color || '#000';
- ctx.beginPath();
- ctx.moveTo(cx, cy);
- ctx.arc(cx, cy, radius, -halfPI, radians - halfPI, false);
- ctx.closePath();
- ctx.fill();
- if (!color) ctx.globalCompositeOperation = 'source-over';
- }
-}
diff --git a/src/gui/mapshaper-progress-message.js b/src/gui/mapshaper-progress-message.js
deleted file mode 100644
index e128710a0..000000000
--- a/src/gui/mapshaper-progress-message.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/* @requires mapshaper-gui-lib */
-
-gui.showProgressMessage = function(msg) {
- if (!gui.progressMessage) {
- gui.progressMessage = El('div').id('progress-message')
- .appendTo('body');
- }
- El('').text(msg).appendTo(gui.progressMessage.empty().show());
-};
-
-gui.clearProgressMessage = function() {
- if (gui.progressMessage) gui.progressMessage.hide();
-};
diff --git a/src/gui/mapshaper-queue-sync.js b/src/gui/mapshaper-queue-sync.js
deleted file mode 100644
index 637c1f942..000000000
--- a/src/gui/mapshaper-queue-sync.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Run a series of tasks in sequence. Each task can be run after a timeout.
-gui.queueSync = function() {
- var tasks = [],
- timeouts = [];
- function runNext() {
- if (tasks.length > 0) {
- setTimeout(function() {
- tasks.shift()();
- runNext();
- }, timeouts.shift());
- }
- }
- return {
- defer: function(task, timeout) {
- tasks.push(task);
- timeouts.push(timeout | 0);
- return this;
- },
- run: runNext
- };
-};
diff --git a/src/gui/mapshaper-repair-control.js b/src/gui/mapshaper-repair-control.js
index 0777511b5..3597598dc 100644
--- a/src/gui/mapshaper-repair-control.js
+++ b/src/gui/mapshaper-repair-control.js
@@ -1,100 +1,109 @@
/* @requires mapshaper-gui-lib */
-function RepairControl(model, map) {
- var el = El("#intersection-display"),
- readout = el.findChild("#intersection-count"),
- btn = el.findChild("#repair-btn"),
- _self = this,
- _dataset, _currXX;
+function RepairControl(gui) {
+ var map = gui.map,
+ model = gui.model,
+ el = gui.container.findChild(".intersection-display"),
+ readout = el.findChild(".intersection-count"),
+ repairBtn = el.findChild(".repair-btn"),
+ // keeping a reference to current arcs and intersections, so intersections
+ // don't need to be recalculated when 'repair' button is pressed.
+ _currArcs,
+ _currXX;
+
+ gui.on('simplify_drag_start', hide);
+ gui.on('simplify_drag_end', updateAsync);
model.on('update', function(e) {
- if (e.flags.simplify || e.flags.proj || e.flags.arc_count || e.flags.affine) {
- // these changes require nulling out any cached intersection data and recalculating
- if (_dataset) {
- _dataset.info.intersections = null;
- _dataset = null;
- _self.hide();
- }
- delayedUpdate();
- } else if (e.flags.select) {
- _self.hide();
- if (!e.flags.import) {
- // Don't recalculate if a dataset was just imported -- another layer may be
- // selected right away.
- reset();
- delayedUpdate();
+ var flags = e.flags;
+ var needUpdate = flags.simplify || flags.proj || flags.arc_count ||
+ flags.affine || flags.points || flags['merge-layers'] || flags.select;
+ if (needUpdate) {
+ if (flags.select) {
+ // preserve cached intersections
+ } else {
+ // delete any cached intersection data
+ e.dataset.info.intersections = null;
}
+ updateAsync();
}
});
- gui.on('mode', function(e) {
- if (e.prev == 'import') {
- // update if import just finished and a new dataset is being edited
- delayedUpdate();
- }
- });
-
- btn.on('click', function() {
- var fixed = internal.repairIntersections(_dataset.arcs, _currXX);
- showIntersections(fixed);
- btn.addClass('disabled');
+ repairBtn.on('click', function() {
+ var fixed = internal.repairIntersections(_currArcs, _currXX);
+ showIntersections(fixed, _currArcs);
+ repairBtn.addClass('disabled');
model.updated({repair: true});
});
- this.hide = function() {
+ function hide() {
el.hide();
- map.setHighlightLayer(null);
- };
+ map.setIntersectionLayer(null);
+ }
+
+ function enabledForDataset(dataset) {
+ var info = dataset.info || {};
+ var opts = info.import_options || {};
+ return !opts.no_repair && !info.no_intersections;
+ }
+
+ // Delay intersection calculation, so map can redraw after previous
+ // operation (e.g. layer load, simplification change)
+ function updateAsync() {
+ reset();
+ setTimeout(updateSync, 10);
+ }
- // Detect and display intersections for current level of arc simplification
- this.update = function() {
- var XX, showBtn, pct;
- if (!_dataset) return;
- if (_dataset.arcs.getRetainedInterval() > 0) {
+ function updateSync() {
+ var e = model.getActiveLayer();
+ var dataset = e.dataset;
+ var arcs = dataset && dataset.arcs;
+ var XX, showBtn;
+ if (!arcs || !internal.layerHasPaths(e.layer) || !enabledForDataset(dataset)) return;
+ if (arcs.getRetainedInterval() > 0) {
// TODO: cache these intersections
- XX = internal.findSegmentIntersections(_dataset.arcs);
+ XX = internal.findSegmentIntersections(arcs);
showBtn = XX.length > 0;
} else { // no simplification
- XX = _dataset.info.intersections;
+ XX = dataset.info.intersections;
if (!XX) {
// cache intersections at 0 simplification, to avoid recalculating
// every time the simplification slider is set to 100% or the layer is selected at 100%
- XX = _dataset.info.intersections = internal.findSegmentIntersections(_dataset.arcs);
+ XX = dataset.info.intersections = internal.findSegmentIntersections(arcs);
}
showBtn = false;
}
el.show();
- showIntersections(XX);
- btn.classed('disabled', !showBtn);
- };
-
- function delayedUpdate() {
- setTimeout(function() {
- var e = model.getActiveLayer();
- if (e.dataset && e.dataset != _dataset && !e.dataset.info.no_repair &&
- internal.layerHasPaths(e.layer)) {
- _dataset = e.dataset;
- _self.update();
- }
- }, 10);
+ showIntersections(XX, arcs);
+ repairBtn.classed('disabled', !showBtn);
}
function reset() {
- _dataset = null;
+ _currArcs = null;
_currXX = null;
- _self.hide();
+ hide();
+ }
+
+ function dismiss() {
+ var dataset = model.getActiveLayer().dataset;
+ dataset.info.intersections = null;
+ dataset.info.no_intersections = true;
+ reset();
}
- function showIntersections(XX) {
+ function showIntersections(XX, arcs) {
var n = XX.length, pointLyr;
_currXX = XX;
+ _currArcs = arcs;
if (n > 0) {
+ // console.log("first intersection:", internal.getIntersectionDebugData(XX[0], arcs));
pointLyr = {geometry_type: 'point', shapes: [internal.getIntersectionPoints(XX)]};
- map.setHighlightLayer(pointLyr, {layers:[pointLyr]});
- readout.text(utils.format("%s line intersection%s", n, utils.pluralSuffix(n)));
+ map.setIntersectionLayer(pointLyr, {layers:[pointLyr]});
+ readout.html(utils.format('
%s line intersection%s
', n, utils.pluralSuffix(n)));
+ readout.findChild('.close-btn').on('click', dismiss);
} else {
- map.setHighlightLayer(null);
- readout.text('');
+ map.setIntersectionLayer(null);
+ readout.html('');
}
}
}
diff --git a/src/gui/mapshaper-shape-hit.js b/src/gui/mapshaper-shape-hit.js
new file mode 100644
index 000000000..68869a9f6
--- /dev/null
+++ b/src/gui/mapshaper-shape-hit.js
@@ -0,0 +1,179 @@
+
+
+function getShapeHitTest(displayLayer, ext) {
+ var geoType = displayLayer.layer.geometry_type;
+ var test;
+ if (geoType == 'point' && displayLayer.style.type == 'styled') {
+ test = getGraduatedCircleTest(getRadiusFunction(displayLayer.style));
+ } else if (geoType == 'point') {
+ test = pointTest;
+ } else if (geoType == 'polyline') {
+ test = polylineTest;
+ } else if (geoType == 'polygon') {
+ test = polygonTest;
+ } else {
+ error("Unexpected geometry type:", geoType);
+ }
+ return test;
+
+ // Convert pixel distance to distance in coordinate units.
+ function getHitBuffer(pix) {
+ return pix / ext.getTransform().mx;
+ }
+
+ // reduce hit threshold when zoomed out
+ function getHitBuffer2(pix, minPix) {
+ var scale = ext.scale();
+ if (scale < 1) {
+ pix *= scale;
+ }
+ if (minPix > 0 && pix < minPix) pix = minPix;
+ return getHitBuffer(pix);
+ }
+
+ function polygonTest(x, y) {
+ var maxDist = getHitBuffer2(5, 1),
+ cands = findHitCandidates(x, y, maxDist),
+ hits = [],
+ cand, hitId;
+ for (var i=0; i
0 && hits.length === 0) {
+ // secondary detection: proximity, if not inside a polygon
+ sortByDistance(x, y, cands, displayLayer.arcs);
+ hits = pickNearestCandidates(cands, 0, maxDist);
+ }
+ return hits;
+ }
+
+ function pickNearestCandidates(sorted, bufDist, maxDist) {
+ var hits = [],
+ cand, minDist;
+ for (var i=0; i bufDist) {
+ break;
+ }
+ hits.push(cand.id);
+ }
+ return hits;
+ }
+
+ function polylineTest(x, y) {
+ var maxDist = getHitBuffer2(15, 2),
+ bufDist = getHitBuffer2(0.05), // tiny threshold for hitting almost-identical lines
+ cands = findHitCandidates(x, y, maxDist);
+ sortByDistance(x, y, cands, displayLayer.arcs);
+ return pickNearestCandidates(cands, bufDist, maxDist);
+ }
+
+ function sortByDistance(x, y, cands, arcs) {
+ for (var i=0; i limit * limit) return;
+ rpix = radius(id);
+ r = getHitBuffer(rpix + 1); // increase effective radius to make small bubbles easier to hit in clusters
+ d = Math.sqrt(distSq) - r; // pointer distance from edge of circle (negative = inside)
+ isOver = d < 0;
+ isNear = d < margin;
+ if (!isNear || rpix > 0 === false) {
+ isHit = false;
+ } else if (hits.length === 0) {
+ isHit = isNear;
+ } else if (!directHit && isOver) {
+ isHit = true;
+ } else if (directHit && isOver) {
+ isHit = r == hitRadius ? d <= hitDist : r < hitRadius; // smallest bubble wins if multiple direct hits
+ } else if (!directHit && !isOver) {
+ // closest to bubble edge wins
+ isHit = hitDist == d ? r <= hitRadius : d < hitDist; // closest bubble wins if multiple indirect hits
+ }
+ if (isHit) {
+ if (hits.length > 0 && (r != hitRadius || d != hitDist)) {
+ hits = [];
+ }
+ hitRadius = r;
+ hitDist = d;
+ directHit = isOver;
+ hits.push(id);
+ }
+ });
+ return hits;
+ };
+ }
+
+ function findHitCandidates(x, y, dist) {
+ var arcs = displayLayer.arcs,
+ index = {},
+ cands = [],
+ bbox = [];
+ displayLayer.layer.shapes.forEach(function(shp, shpId) {
+ var cand;
+ for (var i = 0, n = shp && shp.length; i < n; i++) {
+ arcs.getSimpleShapeBounds2(shp[i], bbox);
+ if (x + dist < bbox[0] || x - dist > bbox[2] ||
+ y + dist < bbox[1] || y - dist > bbox[3]) {
+ continue; // bbox non-intersection
+ }
+ cand = index[shpId];
+ if (!cand) {
+ cand = index[shpId] = {shape: [], id: shpId, dist: 0};
+ cands.push(cand);
+ }
+ cand.shape.push(shp[i]);
+ }
+ });
+ return cands;
+ }
+}
diff --git a/src/gui/mapshaper-sidebar-buttons.js b/src/gui/mapshaper-sidebar-buttons.js
new file mode 100644
index 000000000..31aa00259
--- /dev/null
+++ b/src/gui/mapshaper-sidebar-buttons.js
@@ -0,0 +1,52 @@
+
+function SidebarButtons(gui) {
+ var root = gui.container.findChild('.mshp-main-map');
+ var buttons = El('div').addClass('nav-buttons').appendTo(root).hide();
+ var _hidden = false;
+ gui.on('active', updateVisibility);
+ gui.on('inactive', updateVisibility);
+
+ // @iconRef: selector for an (svg) button icon
+ this.addButton = function(iconRef) {
+ var btn = initButton(iconRef).addClass('nav-btn');
+ btn.appendTo(buttons);
+ return btn;
+ };
+
+ this.addDoubleButton = function(icon1Ref, icon2Ref) {
+ var btn1 = initButton(icon1Ref).addClass('nav-btn');
+ var btn2 = initButton(icon2Ref).addClass('nav-sub-btn');
+ var wrapper = El('div').addClass('nav-btn-wrapper');
+ btn1.appendTo(wrapper);
+ btn2.appendTo(wrapper);
+ wrapper.appendTo(buttons);
+ return [btn1, btn2];
+ };
+
+ this.show = function() {
+ _hidden = false;
+ updateVisibility();
+ };
+
+ this.hide = function() {
+ _hidden = true;
+ updateVisibility();
+ };
+
+ function updateVisibility() {
+ if (GUI.isActiveInstance(gui) && !_hidden) {
+ buttons.show();
+ } else {
+ buttons.hide();
+ }
+ }
+
+ function initButton(iconRef) {
+ var icon = El('body').findChild(iconRef).node().cloneNode(true);
+ var btn = El('div')
+ .on('dblclick', function(e) {e.stopPropagation();}); // block dblclick zoom
+ btn.appendChild(icon);
+ if (icon.hasAttribute('id')) icon.removeAttribute('id');
+ return btn;
+ }
+}
diff --git a/src/gui/mapshaper-simplify-control.js b/src/gui/mapshaper-simplify-control.js
index 995a96b85..e23e4a069 100644
--- a/src/gui/mapshaper-simplify-control.js
+++ b/src/gui/mapshaper-simplify-control.js
@@ -1,14 +1,39 @@
-/* @requires mapshaper-elements, mapshaper-mode-button, mapshaper-slider */
+/* @requires mapshaper-elements, mapshaper-slider, mapshaper-simplify-pct */
-var SimplifyControl = function(model) {
- var control = new EventDispatcher();
+/*
+How changes in the simplify control should affect other components
+
+data calculated, 100% simplification
+ -> [map] filtered arcs update
+
+data calculated, <100% simplification
+ -> [map] filtered arcs update, redraw; [repair] intersection update
+
+change via text field
+ -> [map] redraw; [repair] intersection update
+
+slider drag start
+ -> [repair] hide display
+
+slider drag
+ -> [map] redraw
+
+slider drag end
+ -> [repair] intersection update
+
+*/
+
+var SimplifyControl = function(gui) {
+ var model = gui.model;
+ var control = {};
var _value = 1;
- var el = El('#simplify-control-wrapper');
- var menu = El('#simplify-options');
- var slider, text;
+ var el = gui.container.findChild('.simplify-control-wrapper');
+ var menu = gui.container.findChild('.simplify-options');
+ var slider, text, fromPct;
- new SimpleButton('#simplify-options .submit-btn').on('click', onSubmit);
- new SimpleButton('#simplify-options .cancel-btn').on('click', function() {
+ // init settings menu
+ new SimpleButton(menu.findChild('.submit-btn').addClass('default-btn')).on('click', onSubmit);
+ new SimpleButton(menu.findChild('.cancel-btn')).on('click', function() {
if (el.visible()) {
// cancel just hides menu if slider is visible
menu.hide();
@@ -16,39 +41,42 @@ var SimplifyControl = function(model) {
gui.clearMode();
}
});
- new SimpleButton('#simplify-settings-btn').on('click', function() {
+ new SimpleButton(el.findChild('.simplify-settings-btn')).on('click', function() {
if (menu.visible()) {
menu.hide();
} else {
- initMenu();
+ showMenu();
}
});
+ gui.keyboard.onMenuSubmit(menu, onSubmit);
- new ModeButton('#simplify-btn', 'simplify');
- gui.addMode('simplify', turnOn, turnOff);
+ // init simplify button and mode
+ gui.addMode('simplify', turnOn, turnOff, gui.container.findChild('.simplify-btn'));
model.on('select', function() {
if (gui.getMode() == 'simplify') gui.clearMode();
});
// exit simplify mode when user clicks off the visible part of the menu
- menu.on('click', gui.handleDirectEvent(gui.clearMode));
+ menu.on('click', GUI.handleDirectEvent(gui.clearMode));
- slider = new Slider("#simplify-control .slider");
- slider.handle("#simplify-control .handle");
- slider.track("#simplify-control .track");
+ // init slider
+ slider = new Slider(el.findChild(".simplify-control .slider"));
+ slider.handle(el.findChild(".simplify-control .handle"));
+ slider.track(el.findChild(".simplify-control .track"));
slider.on('change', function(e) {
var pct = fromSliderPct(e.pct);
text.value(pct);
pct = utils.parsePercent(text.text()); // use rounded value (for consistency w/ cli)
- onchange(pct);
+ onChange(pct);
});
slider.on('start', function(e) {
- control.dispatchEvent('simplify-start');
+ gui.dispatchEvent('simplify_drag_start'); // trigger intersection control to hide
}).on('end', function(e) {
- control.dispatchEvent('simplify-end');
+ gui.dispatchEvent('simplify_drag_end'); // trigger intersection control to redraw
});
- text = new ClickText("#simplify-control .clicktext");
+ // init text box showing simplify pct
+ text = new ClickText(el.findChild(".simplify-control .clicktext"));
text.bounds(0, 1);
text.formatter(function(val) {
if (isNaN(val)) return '-';
@@ -70,34 +98,55 @@ var SimplifyControl = function(model) {
text.on('change', function(e) {
var pct = e.value;
slider.pct(toSliderPct(pct));
- control.dispatchEvent('simplify-start');
- onchange(pct);
- control.dispatchEvent('simplify-end');
+ onChange(pct);
+ gui.dispatchEvent('simplify_drag_end'); // (kludge) trigger intersection control to redraw
});
+ control.reset = function() {
+ control.value(1);
+ el.hide();
+ menu.hide();
+ gui.container.removeClass('simplify');
+ };
+
+ control.value = function(val) {
+ if (!isNaN(val)) {
+ // TODO: validate
+ _value = val;
+ slider.pct(toSliderPct(val));
+ text.value(val);
+ }
+ return _value;
+ };
+
+ control.value(_value);
+
function turnOn() {
var target = model.getActiveLayer();
+ var arcs = target.dataset.arcs;
if (!internal.layerHasPaths(target.layer)) {
gui.alert("This layer can not be simplified");
return;
}
- if (target.dataset.arcs.getVertexData().zz) {
+ if (arcs.getVertexData().zz) {
// TODO: try to avoid calculating pct (slow);
showSlider(); // need to show slider before setting; TODO: fix
- control.value(target.dataset.arcs.getRetainedPct());
+ fromPct = internal.getThresholdFunction(arcs, false);
+ control.value(arcs.getRetainedPct());
+
} else {
- initMenu();
+ showMenu();
}
}
- function initMenu() {
+ function showMenu() {
var dataset = model.getActiveLayer().dataset;
var showPlanarOpt = !dataset.arcs.isPlanar();
var opts = internal.getStandardSimplifyOpts(dataset, dataset.info && dataset.info.simplify);
- El('#planar-opt-wrapper').node().style.display = showPlanarOpt ? 'block' : 'none';
- El('#planar-opt').node().checked = !opts.spherical;
- El("#import-retain-opt").node().checked = opts.keep_shapes;
- El("#simplify-options input[value=" + opts.method + "]").node().checked = true;
+ menu.findChild('.planar-opt-wrapper').node().style.display = showPlanarOpt ? 'block' : 'none';
+ menu.findChild('.planar-opt').node().checked = !opts.spherical;
+ menu.findChild('.import-retain-opt').node().checked = opts.keep_shapes;
+ menu.findChild('input[value=' + opts.method + ']').node().checked = true;
menu.show();
}
@@ -119,29 +168,29 @@ var SimplifyControl = function(model) {
var opts = getSimplifyOptions();
mapshaper.simplify(dataset, opts);
model.updated({
- // use presimplify flag if no vertices are removed
- // (to trigger map redraw without recalculating intersections)
- presimplify: opts.pct == 1,
- simplify: opts.pct < 1
+ // trigger filtered arc rebuild without redraw if pct is 1
+ simplify_method: opts.percentage == 1,
+ simplify: opts.percentage < 1
});
showSlider();
+ fromPct = internal.getThresholdFunction(dataset.arcs, false);
gui.clearProgressMessage();
}, delay);
}
function showSlider() {
el.show();
- El('body').addClass('simplify'); // for resizing, hiding layer label, etc.
+ gui.container.addClass('simplify'); // for resizing, hiding layer label, etc.
}
function getSimplifyOptions() {
- var method = El('#simplify-options input[name=method]:checked').attr('value') || null;
+ var method = menu.findChild('input[name=method]:checked').attr('value') || null;
return {
method: method,
- pct: _value,
+ percentage: _value,
no_repair: true,
- keep_shapes: !!El("#import-retain-opt").node().checked,
- planar: !!El('#planar-opt').node().checked
+ keep_shapes: !!menu.findChild('.import-retain-opt').node().checked,
+ planar: !!menu.findChild('.planar-opt').node().checked
};
}
@@ -156,30 +205,18 @@ var SimplifyControl = function(model) {
return pct * pct;
}
- function onchange(val) {
- if (_value != val) {
- _value = val;
- control.dispatchEvent('change', {value:val});
+ function onChange(pct) {
+ if (_value != pct) {
+ _value = pct;
+ model.getActiveLayer().dataset.arcs.setRetainedInterval(fromPct(pct));
+ model.updated({'simplify_amount': true});
+ updateSliderDisplay();
}
}
- control.reset = function() {
- control.value(1);
- el.hide();
- menu.hide();
- El('body').removeClass('simplify');
- };
-
- control.value = function(val) {
- if (!isNaN(val)) {
- // TODO: validate
- _value = val;
- slider.pct(toSliderPct(val));
- text.value(val);
- }
- return _value;
- };
-
- control.value(_value);
- return control;
+ function updateSliderDisplay() {
+ // TODO: display resolution and vertex count
+ // var dataset = model.getActiveLayer().dataset;
+ // var interval = dataset.arcs.getRetainedInterval();
+ }
};
diff --git a/src/gui/mapshaper-slider.js b/src/gui/mapshaper-slider.js
index 21695c625..19064c485 100644
--- a/src/gui/mapshaper-slider.js
+++ b/src/gui/mapshaper-slider.js
@@ -106,4 +106,4 @@ function Slider(ref, opts) {
}
}
-utils.inherit(Slider, EventDispatcher);
\ No newline at end of file
+utils.inherit(Slider, EventDispatcher);
diff --git a/src/gui/mapshaper-svg-display.js b/src/gui/mapshaper-svg-display.js
new file mode 100644
index 000000000..33cc4de51
--- /dev/null
+++ b/src/gui/mapshaper-svg-display.js
@@ -0,0 +1,64 @@
+/* @requires
+mapshaper-gui-lib
+mapshaper-svg-symbols
+mapshaper-svg-furniture
+*/
+
+function SvgDisplayLayer(gui, ext, mouse) {
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ var el = El(svg);
+
+ el.clear = function() {
+ while (svg.childNodes.length > 0) {
+ svg.removeChild(svg.childNodes[0]);
+ }
+ };
+
+ el.reposition = function(target, type) {
+ resize(ext);
+ reposition(target, type, ext);
+ };
+
+ el.drawLayer = function(target, type) {
+ var g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ var html = '';
+ // generate a unique id so layer can be identified when symbols are repositioned
+ // use it as a class name to avoid id collisions
+ var id = utils.getUniqueName();
+ var classNames = [id, 'mapshaper-svg-layer', 'mapshaper-' + type + '-layer'];
+ g.setAttribute('class', classNames.join(' '));
+ target.svg_id = id;
+ resize(ext);
+ if (type == 'label' || type == 'symbol') {
+ html = renderSymbols(target.layer, ext, type);
+ } else if (type == 'furniture') {
+ html = renderFurniture(target.layer, ext);
+ }
+ g.innerHTML = html;
+ svg.append(g);
+
+ // prevent svg hit detection on inactive layers
+ if (!target.active) {
+ g.style.pointerEvents = 'none';
+ }
+ };
+
+ function reposition(target, type, ext) {
+ var container = el.findChild('.' + target.svg_id).node();
+ var elements;
+ if (type == 'label' || type == 'symbol') {
+ elements = type == 'label' ? container.getElementsByTagName('text') :
+ El.findAll('.mapshaper-svg-symbol', container);
+ repositionSymbols(elements, target.layer, ext);
+ } else if (type == 'furniture') {
+ repositionFurniture(container, target.layer, ext);
+ }
+ }
+
+ function resize(ext) {
+ svg.style.width = ext.width() + 'px';
+ svg.style.height = ext.height() + 'px';
+ }
+
+ return el;
+}
diff --git a/src/gui/mapshaper-svg-furniture.js b/src/gui/mapshaper-svg-furniture.js
new file mode 100644
index 000000000..deebb6708
--- /dev/null
+++ b/src/gui/mapshaper-svg-furniture.js
@@ -0,0 +1,23 @@
+function getSvgFurnitureTransform(ext) {
+ var scale = ext.getSymbolScale();
+ var frame = ext.getFrame();
+ var p = ext.translateCoords(frame.bbox[0], frame.bbox[3]);
+ return internal.svg.getTransform(p, scale);
+}
+
+function repositionFurniture(container, layer, ext) {
+ var g = El.findAll('.mapshaper-svg-furniture', container)[0];
+ g.setAttribute('transform', getSvgFurnitureTransform(ext));
+}
+
+function renderFurniture(lyr, ext) {
+ var frame = ext.getFrame(); // frame should be set if we're rendering a furniture layer
+ var obj = internal.getEmptyLayerForSVG(lyr, {});
+ if (!frame) {
+ stop('Missing map frame data');
+ }
+ obj.properties.transform = getSvgFurnitureTransform(ext);
+ obj.properties.class = 'mapshaper-svg-furniture';
+ obj.children = internal.svg.importFurniture(internal.getFurnitureLayerData(lyr), frame);
+ return internal.svg.stringify(obj);
+}
diff --git a/src/gui/mapshaper-svg-hit.js b/src/gui/mapshaper-svg-hit.js
new file mode 100644
index 000000000..cdd7efba7
--- /dev/null
+++ b/src/gui/mapshaper-svg-hit.js
@@ -0,0 +1,63 @@
+/* @requires mapshaper-svg-symbol-utils */
+
+function getSvgHitTest(displayLayer) {
+
+ return function(pointerEvent) {
+ // target could be a part of an SVG symbol, or the SVG element, or something else
+ var target = pointerEvent.originalEvent.target;
+ var symbolNode = getSymbolNode(target);
+ var featureId;
+ if (!symbolNode) {
+ return null;
+ }
+ // TODO: some validation on feature id
+ featureId = getSymbolNodeId(symbolNode);
+ return {
+ id: featureId,
+ ids: [featureId],
+ targetSymbol: symbolNode,
+ targetNode: target,
+ container: symbolNode.parentNode
+ };
+ };
+
+ // target: event target (could be any DOM element)
+ function getSymbolNode(target) {
+ var node = target;
+ while (node && nodeHasSymbolTagType(node)) {
+ if (isSymbolNode(node)) {
+ return node;
+ }
+ node = node.parentElement;
+ }
+ return null;
+ }
+
+ // TODO: switch to attribute detection
+ function nodeHasSymbolTagType(node) {
+ var tag = node.tagName;
+ return tag == 'g' || tag == 'tspan' || tag == 'text' || tag == 'image' ||
+ tag == 'path' || tag == 'circle' || tag == 'rect' || tag == 'line';
+ }
+
+ function isSymbolNode(node) {
+ return node.hasAttribute('data-id') && (node.tagName == 'text' || node.tagName == 'g');
+ }
+
+ function isSymbolChildNode(node) {
+
+ }
+
+ function getChildId(childNode) {
+
+ }
+
+ function getSymbolId(symbolNode) {
+
+ }
+
+ function getFeatureId(symbolNode) {
+
+ }
+
+}
diff --git a/src/gui/mapshaper-svg-labels.js b/src/gui/mapshaper-svg-labels.js
new file mode 100644
index 000000000..57decd64b
--- /dev/null
+++ b/src/gui/mapshaper-svg-labels.js
@@ -0,0 +1,78 @@
+/* @requires mapshaper-svg-symbols */
+
+
+function isMultilineLabel(textNode) {
+ return textNode.childNodes.length > 1;
+}
+
+function toggleTextAlign(textNode, rec) {
+ var curr = rec['text-anchor'] || 'middle';
+ var value = curr == 'middle' && 'start' || curr == 'start' && 'end' || 'middle';
+ updateTextAnchor(value, textNode, rec);
+}
+
+// Set an attribute on a node and any child elements
+// (mapshaper's svg labels require tspans to have the same x and dx values
+// as the enclosing text node)
+function setMultilineAttribute(textNode, name, value) {
+ var n = textNode.childNodes.length;
+ var i = -1;
+ var child;
+ textNode.setAttribute(name, value);
+ while (++i < n) {
+ child = textNode.childNodes[i];
+ if (child.tagName == 'tspan') {
+ child.setAttribute(name, value);
+ }
+ }
+}
+
+function findSvgRoot(el) {
+ while (el && el.tagName != 'html' && el.tagName != 'body') {
+ if (el.tagName == 'svg') return el;
+ el = el.parentNode;
+ }
+ return null;
+}
+
+// p: pixel coordinates of label anchor
+function autoUpdateTextAnchor(textNode, rec, p) {
+ var svg = findSvgRoot(textNode);
+ var rect = textNode.getBoundingClientRect();
+ var labelCenterX = rect.left - svg.getBoundingClientRect().left + rect.width / 2;
+ var xpct = (labelCenterX - p[0]) / rect.width; // offset of label center from anchor center
+ var value = xpct < -0.25 && 'end' || xpct > 0.25 && 'start' || 'middle';
+ updateTextAnchor(value, textNode, rec);
+}
+
+// @value: optional position to set; if missing, auto-set
+function updateTextAnchor(value, textNode, rec) {
+ var rect = textNode.getBoundingClientRect();
+ var width = rect.width;
+ var curr = rec['text-anchor'] || 'middle';
+ var xshift = 0;
+
+ // console.log("anchor() curr:", curr, "xpct:", xpct, "left:", rect.left, "anchorX:", anchorX, "targ:", targ, "dx:", xshift)
+ if (curr == 'middle' && value == 'end' || curr == 'start' && value == 'middle') {
+ xshift = width / 2;
+ } else if (curr == 'middle' && value == 'start' || curr == 'end' && value == 'middle') {
+ xshift = -width / 2;
+ } else if (curr == 'start' && value == 'end') {
+ xshift = width;
+ } else if (curr == 'end' && value == 'start') {
+ xshift = -width;
+ }
+ if (xshift) {
+ rec['text-anchor'] = value;
+ applyDelta(rec, 'dx', xshift);
+ }
+}
+
+
+// handle either numeric strings or numbers in fields
+function applyDelta(rec, key, delta) {
+ var currVal = rec[key];
+ var isString = utils.isString(currVal);
+ var newVal = (+currVal + delta) || 0;
+ rec[key] = isString ? String(newVal) : newVal;
+}
\ No newline at end of file
diff --git a/src/gui/mapshaper-svg-symbol-utils.js b/src/gui/mapshaper-svg-symbol-utils.js
new file mode 100644
index 000000000..82689f27b
--- /dev/null
+++ b/src/gui/mapshaper-svg-symbol-utils.js
@@ -0,0 +1,11 @@
+
+function getSymbolNodeId(node) {
+ return parseInt(node.getAttribute('data-id'));
+}
+
+function getSymbolNodeById(id, parent) {
+ // TODO: optimize selector
+ var sel = '[data-id="' + id + '"]';
+ return parent.querySelector(sel);
+}
+
diff --git a/src/gui/mapshaper-svg-symbols.js b/src/gui/mapshaper-svg-symbols.js
new file mode 100644
index 000000000..62429b209
--- /dev/null
+++ b/src/gui/mapshaper-svg-symbols.js
@@ -0,0 +1,34 @@
+
+
+function getSvgSymbolTransform(xy, ext) {
+ var scale = ext.getSymbolScale();
+ var p = ext.translateCoords(xy[0], xy[1]);
+ return internal.svg.getTransform(p, scale);
+}
+
+function repositionSymbols(elements, layer, ext) {
+ var el, idx, p;
+ for (var i=0, n=elements.length; i stop editing
+ // b: on text
+ // 1: not editing -> nop
+ // 2: on selected text -> start dragging
+ // 3: on other text -> stop dragging, select new text
+
+ hit.on('dragstart', function(e) {
+ if (labelEditingEnabled()) {
+ onLabelDragStart(e);
+ } else if (locationEditingEnabled()) {
+ onLocationDragStart(e);
+ }
+ });
+
+ hit.on('drag', function(e) {
+ if (labelEditingEnabled()) {
+ onLabelDrag(e);
+ } else if (locationEditingEnabled()) {
+ onLocationDrag(e);
+ }
+ });
+
+ hit.on('dragend', function(e) {
+ if (locationEditingEnabled()) {
+ onLocationDragEnd(e);
+ stopDragging();
+ } else if (labelEditingEnabled()) {
+ stopDragging();
+ }
+ });
+
+ hit.on('click', function(e) {
+ if (labelEditingEnabled()) {
+ onLabelClick(e);
+ }
+ });
+
+ function onLocationDragStart(e) {
+ if (e.id >= 0) {
+ dragging = true;
+ triggerGlobalEvent('symbol_dragstart', e);
+ }
+ }
+
+ function onLocationDrag(e) {
+ var lyr = hit.getHitTarget().layer;
+ // get reference to
+ var p = getPointCoordsById(e.id, hit.getHitTarget().layer);
+ if (!p) return;
+ var diff = translateDeltaDisplayCoords(e.dx, e.dy, ext);
+ p[0] += diff[0];
+ p[1] += diff[1];
+ self.dispatchEvent('location_change'); // signal map to redraw
+ triggerGlobalEvent('symbol_drag', e);
+ }
+
+ function onLocationDragEnd(e) {
+ triggerGlobalEvent('symbol_dragend', e);
+ }
+
+ function onLabelClick(e) {
+ var textNode = getTextTarget3(e);
+ var rec = getLabelRecordById(e.id);
+ if (textNode && rec && isMultilineLabel(textNode)) {
+ toggleTextAlign(textNode, rec);
+ updateSymbol2(textNode, rec, e.id);
+ // e.stopPropagation(); // prevent pin/unpin on popup
+ }
+ }
+
+ function triggerGlobalEvent(type, e) {
+ if (e.id >= 0) {
+ // fire event to signal external editor that symbol coords have changed
+ gui.dispatchEvent(type, {FID: e.id, layer_name: hit.getHitTarget().layer.name});
+ }
+ }
+
+ function getLabelRecordById(id) {
+ var table = hit.getTargetDataTable();
+ if (id >= 0 === false || !table) return null;
+ // add dx and dy properties, if not available
+ if (!table.fieldExists('dx')) {
+ table.addField('dx', 0);
+ }
+ if (!table.fieldExists('dy')) {
+ table.addField('dy', 0);
+ }
+ if (!table.fieldExists('text-anchor')) {
+ table.addField('text-anchor', '');
+ }
+ return table.getRecordAt(id);
+ }
+
+ function onLabelDragStart(e) {
+ var textNode = getTextTarget3(e);
+ var table = hit.getTargetDataTable();
+ if (!textNode || !table) return;
+ activeId = e.id;
+ activeRecord = getLabelRecordById(activeId);
+ dragging = true;
+ downEvt = e;
+ }
+
+ function onLabelDrag(e) {
+ var scale = ext.getSymbolScale() || 1;
+ var textNode;
+ if (!dragging) return;
+ if (e.id != activeId) {
+ error("Mismatched hit ids:", e.id, activeId);
+ }
+ applyDelta(activeRecord, 'dx', e.dx / scale);
+ applyDelta(activeRecord, 'dy', e.dy / scale);
+ textNode = getTextTarget3(e);
+ if (!isMultilineLabel(textNode)) {
+ // update anchor position of single-line labels based on label position
+ // relative to anchor point, for better placement when eventual display font is
+ // different from mapshaper's font.
+ autoUpdateTextAnchor(textNode, activeRecord, getDisplayCoordsById(activeId, hit.getHitTarget().layer, ext));
+ }
+ // updateSymbol(targetTextNode, activeRecord);
+ updateSymbol2(textNode, activeRecord, activeId);
+ }
+
+ function getTextTarget3(e) {
+ if (e.id > -1 === false || !e.container) return null;
+ return getSymbolNodeById(e.id, e.container);
+ }
+
+ function getTextTarget2(e) {
+ var el = e && e.targetSymbol || null;
+ if (el && el.tagName == 'tspan') {
+ el = el.parentNode;
+ }
+ return el && el.tagName == 'text' ? el : null;
+ }
+
+ function getTextTarget(e) {
+ var el = e.target;
+ if (el.tagName == 'tspan') {
+ el = el.parentNode;
+ }
+ return el.tagName == 'text' ? el : null;
+ }
+
+ // svg.addEventListener('mousedown', function(e) {
+ // var textTarget = getTextTarget(e);
+ // downEvt = e;
+ // if (!textTarget) {
+ // stopEditing();
+ // } else if (!editing) {
+ // // nop
+ // } else if (textTarget == targetTextNode) {
+ // startDragging();
+ // } else {
+ // startDragging();
+ // editTextNode(textTarget);
+ // }
+ // });
+
+ // up event on svg
+ // a: currently dragging text
+ // -> stop dragging
+ // b: clicked on a text feature
+ // -> start editing it
+
+
+ // svg.addEventListener('mouseup', function(e) {
+ // var textTarget = getTextTarget(e);
+ // var isClick = isClickEvent(e, downEvt);
+ // if (isClick && textTarget && textTarget == targetTextNode &&
+ // activeRecord && isMultilineLabel(targetTextNode)) {
+ // toggleTextAlign(targetTextNode, activeRecord);
+ // updateSymbol();
+ // }
+ // if (dragging) {
+ // stopDragging();
+ // } else if (isClick && textTarget) {
+ // editTextNode(textTarget);
+ // }
+ // });
+
+ // block dbl-click navigation when editing
+ // mouse.on('dblclick', function(e) {
+ // if (editing) e.stopPropagation();
+ // }, null, eventPriority);
+
+ // mouse.on('dragstart', function(e) {
+ // onLabelDrag(e);
+ // }, null, eventPriority);
+
+ // mouse.on('drag', function(e) {
+ // var scale = ext.getSymbolScale() || 1;
+ // onLabelDrag(e);
+ // if (!dragging || !activeRecord) return;
+ // applyDelta(activeRecord, 'dx', e.dx / scale);
+ // applyDelta(activeRecord, 'dy', e.dy / scale);
+ // if (!isMultilineLabel(targetTextNode)) {
+ // // update anchor position of single-line labels based on label position
+ // // relative to anchor point, for better placement when eventual display font is
+ // // different from mapshaper's font.
+ // updateTextAnchor(targetTextNode, activeRecord);
+ // }
+ // // updateSymbol(targetTextNode, activeRecord);
+ // targetTextNode = updateSymbol2(targetTextNode, activeRecord, activeId);
+ // }, null, eventPriority);
+
+ // mouse.on('dragend', function(e) {
+ // onLabelDrag(e);
+ // stopDragging();
+ // }, null, eventPriority);
+
+
+ // function onLabelDrag(e) {
+ // if (dragging) {
+ // e.stopPropagation();
+ // }
+ // }
+ }
+
+ function stopDragging() {
+ dragging = false;
+ activeId = -1;
+ activeRecord = null;
+ // targetTextNode = null;
+ // svg.removeAttribute('class');
+ }
+
+ function isClickEvent(up, down) {
+ var elapsed = Math.abs(down.timeStamp - up.timeStamp);
+ var dx = up.screenX - down.screenX;
+ var dy = up.screenY - down.screenY;
+ var dist = Math.sqrt(dx * dx + dy * dy);
+ return dist <= 4 && elapsed < 300;
+ }
+
+
+ // function deselectText(el) {
+ // el.removeAttribute('class');
+ // }
+
+ // function selectText(el) {
+ // el.setAttribute('class', 'selected');
+ // }
+
+
+}
diff --git a/src/gui/mapshaper-zip-reader.js b/src/gui/mapshaper-zip-reader.js
index 24e44be8d..e275d7b57 100644
--- a/src/gui/mapshaper-zip-reader.js
+++ b/src/gui/mapshaper-zip-reader.js
@@ -1,15 +1,9 @@
// Assume zip.js is loaded and zip is defined globally
-zip.workerScripts = {
- // deflater: ['z-worker.js', 'deflate.js'], // use zip.js deflater
- // TODO: find out why it was necessary to rename pako_deflate.min.js
- deflater: ['z-worker.js', 'pako.deflate.js', 'codecs.js'],
- inflater: ['z-worker.js', 'pako.inflate.js', 'codecs.js']
-};
// @file: Zip file
// @cb: function(err, )
//
-gui.readZipFile = function(file, cb) {
+GUI.readZipFile = function(file, cb) {
var _files = [];
zip.createReader(new zip.BlobReader(file), importZipContent, onError);
@@ -41,7 +35,7 @@ gui.readZipFile = function(file, cb) {
function readEntry(entry) {
var filename = entry.filename,
- isValid = !entry.directory && gui.isReadableFileType(filename) &&
+ isValid = !entry.directory && GUI.isReadableFileType(filename) &&
!/^__MACOSX/.test(filename); // ignore "resource-force" files
if (isValid) {
entry.getData(new zip.BlobWriter(), function(file) {
diff --git a/lib/mbloch-gui-lib.js b/src/gui/mbloch-gui-lib.js
similarity index 79%
rename from lib/mbloch-gui-lib.js
rename to src/gui/mbloch-gui-lib.js
index 8a7b9b0ad..f339ef6e6 100644
--- a/lib/mbloch-gui-lib.js
+++ b/src/gui/mbloch-gui-lib.js
@@ -14,7 +14,7 @@ Handler.prototype.trigger = function(evt) {
error("[Handler] event target/type have changed.");
}
this.callback.call(this.listener, evt);
-}
+};
function EventData(type, target, data) {
this.type = type;
@@ -113,25 +113,6 @@ EventDispatcher.prototype.countEventListeners = function(type) {
-var Env = (function() {
- var inNode = typeof module !== 'undefined' && !!module.exports;
- var inBrowser = typeof window !== 'undefined' && !inNode;
- var inPhantom = inBrowser && !!(window.phantom && window.phantom.exit);
- var ieVersion = inBrowser && /MSIE ([0-9]+)/.exec(navigator.appVersion) && parseInt(RegExp.$1) || NaN;
-
- return {
- iPhone : inBrowser && !!(navigator.userAgent.match(/iPhone/i)),
- iPad : inBrowser && !!(navigator.userAgent.match(/iPad/i)),
- canvas: inBrowser && !!document.createElement('canvas').getContext,
- inNode : inNode,
- inPhantom : inPhantom,
- inBrowser: inBrowser,
- ieVersion: ieVersion,
- ie: !isNaN(ieVersion)
- };
-})();
-
-
var Browser = {
getPageXY: function(el) {
var x = 0, y = 0;
@@ -164,15 +145,16 @@ var Browser = {
elementIsFixed: function(el) {
// get top-level offsetParent that isn't body (cf. Firefox)
var body = document.body;
+ var parent;
while (el && el != body) {
- var parent = el;
+ 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 && Browser.getElementStyle(parent) || {};
- return styleObj['position'] == 'fixed';
+ return styleObj.position == 'fixed';
},
pageXToViewportX: function(x) {
@@ -285,59 +267,14 @@ utils.htmlEscape = (function() {
}());
-var classSelectorRE = /^\.([\w-]+)$/,
- idSelectorRE = /^#([\w-]+)$/,
- tagSelectorRE = /^[\w-]+$/,
- tagOrIdSelectorRE = /^#?[\w-]+$/;
-
-function Elements(sel) {
- if ((this instanceof Elements) == false) {
- return new Elements(sel);
- }
- this.elements = [];
- this.select(sel);
-}
-
-Elements.prototype = {
- size: function() {
- return this.elements.length;
- },
-
- select: function(sel) {
- this.elements = Elements.__select(sel);
- return this;
- },
-
- addClass: function(className) {
- this.forEach(function(el) { el.addClass(className); });
- return this;
- },
-
- removeClass: function(className) {
- this.forEach(function(el) { el.removeClass(className); })
- return this;
- },
-
- forEach: function(callback, ctx) {
- for (var i=0, len=this.elements.length; i 0 ? 1 : -1;
+ else if (e.detail) dir = e.detail > 0 ? -1 : 1;
+ if (time - ptime > 300) getAverage = LimitedAverage(3); // reset
+ ptime = time;
+ avg = getAverage(dir) || dir; // handle average == 0
+ return avg > 0 ? 1 : -1;
+ };
+}
+
+function LimitedAverage(maxSize) {
+ var arr = [];
+ return function(val) {
+ var sum = 0,
+ i = -1;
+ arr.push(val);
+ if (arr.length > maxSize) arr.shift();
+ while (++i < arr.length) {
+ sum += arr[i];
+ }
+ return sum / arr.length;
+ };
+}
+
// @mouse: MouseArea object
function MouseWheel(mouse) {
var self = this,
- prevWheelTime = 0,
- currDirection = 0,
+ active = false,
timer = new Timer().addEventListener('tick', onTick),
- sustainTime = 60,
- fadeTime = 80;
+ sustainInterval = 150,
+ fadeDelay = 70,
+ eventTime = 0,
+ getAverageRate = LimitedAverage(10),
+ getWheelDirection = MouseWheelDirection(),
+ wheelDirection;
if (window.onmousewheel !== undefined) { // ie, webkit
window.addEventListener('mousewheel', handleWheel);
@@ -862,36 +785,48 @@ function MouseWheel(mouse) {
window.addEventListener('DOMMouseScroll', handleWheel);
}
+ function updateSustainInterval(eventRate) {
+ var fadeInterval = 80;
+ fadeDelay = eventRate + 50; // adding a little extra time helps keep trackpad scrolling smooth in Firefox
+ sustainInterval = fadeDelay + fadeInterval;
+ }
+
function handleWheel(evt) {
- var direction;
- if (evt.wheelDelta) {
- direction = evt.wheelDelta > 0 ? 1 : -1;
- } else if (evt.detail) {
- direction = evt.detail > 0 ? -1 : 1;
+ var now = +new Date();
+ wheelDirection = getWheelDirection(evt, now);
+ if (evt.ctrlKey) {
+ // Prevent pinch-zoom in Chrome (doesn't work in Safari, though)
+ evt.preventDefault();
+ evt.stopImmediatePropagation();
}
- if (!mouse.isOver() || !direction) return;
+ if (!mouse.isOver()) return;
evt.preventDefault();
- prevWheelTime = +new Date();
- if (!currDirection) {
+ if (!active) {
+ active = true;
self.dispatchEvent('mousewheelstart');
+ } else {
+ updateSustainInterval(getAverageRate(now - eventTime));
}
- currDirection = direction;
- timer.start(sustainTime + fadeTime);
+ eventTime = now;
+ timer.start(sustainInterval);
}
function onTick(evt) {
- var elapsed = evt.time - prevWheelTime,
- fadeElapsed = elapsed - sustainTime,
- scale = evt.tickTime / 25,
+ var tickInterval = evt.time - eventTime,
+ multiplier = evt.tickTime / 25,
+ fadeFactor = 0,
obj;
+ if (tickInterval > fadeDelay) {
+ fadeFactor = Math.min(1, (tickInterval - fadeDelay) / (sustainInterval - fadeDelay));
+ }
if (evt.done) {
- currDirection = 0;
+ active = false;
} else {
- if (fadeElapsed > 0) {
- // Decelerate if the timer fires during 'fade time' (for smoother zooming)
- scale *= Tween.quadraticOut((fadeTime - fadeElapsed) / fadeTime);
+ if (fadeFactor > 0) {
+ // Decelerate towards the end of the sustain interval (for smoother zooming)
+ multiplier *= Tween.quadraticOut(1 - fadeFactor);
}
- obj = utils.extend({direction: currDirection, multiplier: scale}, mouse.mouseData());
+ obj = utils.extend({direction: wheelDirection, multiplier: multiplier}, mouse.mouseData());
self.dispatchEvent('mousewheel', obj);
}
}
@@ -900,17 +835,17 @@ function MouseWheel(mouse) {
utils.inherit(MouseWheel, EventDispatcher);
-function MouseArea(element) {
- var pos = new ElementPosition(element),
- _areaPos = pos.position(),
+function MouseArea(element, pos) {
+ var _pos = pos || new ElementPosition(element),
+ _areaPos = _pos.position(),
_self = this,
_dragging = false,
_isOver = false,
+ _disabled = false,
_prevEvt,
- // _moveEvt,
_downEvt;
- pos.on('change', function() {_areaPos = pos.position()});
+ _pos.on('change', function() {_areaPos = _pos.position();});
// TODO: think about touch events
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mousedown', onMouseDown);
@@ -921,6 +856,39 @@ function MouseArea(element) {
element.addEventListener('mousedown', onAreaDown);
element.addEventListener('dblclick', onAreaDblClick);
+ this.enable = function() {
+ if (!_disabled) return;
+ _disabled = false;
+ element.style.pointerEvents = 'auto';
+ };
+
+ this.stopDragging = function() {
+ if (_downEvt) {
+ if (_dragging) stopDragging(_downEvt);
+ _downEvt = null;
+ }
+ };
+
+ this.disable = function() {
+ if (_disabled) return;
+ _disabled = true;
+ if (_isOver) onAreaOut();
+ this.stopDragging();
+ element.style.pointerEvents = 'none';
+ };
+
+ this.isOver = function() {
+ return _isOver;
+ };
+
+ this.isDown = function() {
+ return !!_downEvt;
+ };
+
+ this.mouseData = function() {
+ return utils.extend({}, _prevEvt);
+ };
+
function onAreaDown(e) {
e.preventDefault(); // prevent text selection cursor on drag
}
@@ -932,7 +900,7 @@ function MouseArea(element) {
}
}
- function onAreaOut(e) {
+ function onAreaOut() {
_isOver = false;
_self.dispatchEvent('leave');
}
@@ -941,8 +909,7 @@ function MouseArea(element) {
var evt = procMouseEvent(e),
elapsed, dx, dy;
if (_dragging) {
- _dragging = false;
- _self.dispatchEvent('dragend', evt);
+ stopDragging(evt);
}
if (_downEvt) {
elapsed = evt.time - _downEvt.time;
@@ -955,6 +922,11 @@ function MouseArea(element) {
}
}
+ function stopDragging(evt) {
+ _dragging = false;
+ _self.dispatchEvent('dragend', evt);
+ }
+
function onMouseDown(e) {
if (e.button != 2 && e.which != 3) { // ignore right-click
_downEvt = procMouseEvent(e);
@@ -967,7 +939,7 @@ function MouseArea(element) {
_dragging = true;
_self.dispatchEvent('dragstart', evt);
}
-
+ if (evt.dx === 0 && evt.dy === 0) return; // seen in Chrome
if (_dragging) {
var obj = {
dragX: evt.pageX - _downEvt.pageX,
@@ -988,8 +960,9 @@ function MouseArea(element) {
pageY = e.pageY,
prev = _prevEvt;
_prevEvt = {
+ originalEvent: e,
shiftKey: e.shiftKey,
- time: +new Date,
+ time: +new Date(),
pageX: pageX,
pageY: pageY,
hover: _isOver,
@@ -1000,18 +973,6 @@ function MouseArea(element) {
};
return _prevEvt;
}
-
- this.isOver = function() {
- return _isOver;
- }
-
- this.isDown = function() {
- return !!_downEvt;
- }
-
- this.mouseData = function() {
- return utils.extend({}, _prevEvt);
- }
}
utils.inherit(MouseArea, EventDispatcher);
diff --git a/src/io/mapshaper-export.js b/src/io/mapshaper-export.js
index f6aa193b9..ec2569e7b 100644
--- a/src/io/mapshaper-export.js
+++ b/src/io/mapshaper-export.js
@@ -39,18 +39,20 @@ internal.exportDatasets = function(datasets, opts) {
}
// KLUDGE let exporter know that copying is not needed
// (because shape data was deep-copied during merge)
- opts.final = true;
+ opts = utils.defaults({final: true}, opts);
}
} else {
datasets = datasets.map(internal.copyDatasetForRenaming);
internal.assignUniqueLayerNames2(datasets);
}
files = datasets.reduce(function(memo, dataset) {
- if (opts.target) {
- // kludge to export layers in order that target= option matched them
- // (useful mainly for SVG output)
- // match_id was assigned to each layer by findCommandTargets()
- utils.sortOn(dataset.layers, 'match_id', true);
+ if (internal.runningInBrowser()) {
+ utils.sortOn(dataset.layers, 'stack_id', true);
+ } else {
+ // kludge to export layers in order that target= option or previous
+ // -target command matched them (useful mainly for SVG output)
+ // target_id was assigned to each layer by findCommandTargets()
+ utils.sortOn(dataset.layers, 'target_id', true);
}
return memo.concat(internal.exportFileContent(dataset, opts));
}, []);
@@ -67,9 +69,9 @@ internal.exportFileContent = function(dataset, opts) {
files = [];
if (!outFmt) {
- error("[o] Missing output format");
+ error("Missing output format");
} else if (!exporter) {
- error("[o] Unknown output format:", outFmt);
+ error("Unknown output format:", outFmt);
}
// shallow-copy dataset and layers, so layers can be renamed for export
@@ -78,16 +80,19 @@ internal.exportFileContent = function(dataset, opts) {
}, dataset);
// Adjust layer names, so they can be used as output file names
- if (opts.file && outFmt != 'topojson') {
+ // (except for multi-layer formats TopoJSON and SVG)
+ if (opts.file && outFmt != 'topojson' && outFmt != 'svg') {
dataset.layers.forEach(function(lyr) {
lyr.name = utils.getFileBase(opts.file);
});
}
internal.assignUniqueLayerNames(dataset.layers);
- // apply coordinate precision, except for svg precision, which is applied
- // during export, after rescaling
- if (opts.precision && outFmt != 'svg') {
+ // apply coordinate precision, except:
+ // svg precision is applied by the SVG exporter, after rescaling
+ // GeoJSON precision is applied by the exporter, to handle default precision
+ // TopoJSON precision is applied to avoid redundant copying
+ if (opts.precision && outFmt != 'svg' && outFmt != 'geojson' && outFmt != 'topojson') {
dataset = internal.copyDatasetForExport(dataset);
internal.setCoordinatePrecision(dataset, opts.precision);
}
@@ -165,14 +170,14 @@ internal.validateLayerData = function(layers) {
if (lyr.shapes && utils.some(lyr.shapes, function(o) {
return !!o;
})) {
- error("[export] A layer contains shape records and a null geometry type");
+ error("A layer contains shape records and a null geometry type");
}
} else {
if (!utils.contains(['polygon', 'polyline', 'point'], lyr.geometry_type)) {
- error ("[export] A layer has an invalid geometry type:", lyr.geometry_type);
+ error ("A layer has an invalid geometry type:", lyr.geometry_type);
}
if (!lyr.shapes) {
- error ("[export] A layer is missing shape data");
+ error ("A layer is missing shape data");
}
}
});
@@ -182,8 +187,8 @@ internal.validateFileNames = function(files) {
var index = {};
files.forEach(function(file, i) {
var filename = file.filename;
- if (!filename) error("[o] Missing a filename for file" + i);
- if (filename in index) error("[o] Duplicate filename", filename);
+ if (!filename) error("Missing a filename for file" + i);
+ if (filename in index) error("Duplicate filename", filename);
index[filename] = true;
});
};
diff --git a/src/io/mapshaper-file-export.js b/src/io/mapshaper-file-export.js
index a5dd8e49e..eef5115b9 100644
--- a/src/io/mapshaper-file-export.js
+++ b/src/io/mapshaper-file-export.js
@@ -11,6 +11,7 @@ internal.writeFiles = function(exports, opts, cb) {
return cli.writeFile('/dev/stdout', exports[0].content, cb);
} else {
var paths = internal.getOutputPaths(utils.pluck(exports, 'filename'), opts);
+ var inputFiles = internal.getStateVar('input_files');
exports.forEach(function(obj, i) {
var path = paths[i];
if (obj.content instanceof ArrayBuffer) {
@@ -20,6 +21,9 @@ internal.writeFiles = function(exports, opts, cb) {
if (opts.output) {
opts.output.push({filename: path, content: obj.content});
} else {
+ if (!opts.force && inputFiles.indexOf(path) > -1) {
+ stop('Need to use the "-o force" option to overwrite input files.');
+ }
cli.writeFile(path, obj.content);
message("Wrote " + path);
}
@@ -30,9 +34,6 @@ internal.writeFiles = function(exports, opts, cb) {
internal.getOutputPaths = function(files, opts) {
var odir = opts.directory;
- if (opts.force) {
- message("[o] The force option is obsolete, files are now overwritten by default");
- }
if (odir) {
files = files.map(function(file) {
return require('path').join(odir, file);
diff --git a/src/io/mapshaper-file-import.js b/src/io/mapshaper-file-import.js
index 7c6f3201d..78c2be609 100644
--- a/src/io/mapshaper-file-import.js
+++ b/src/io/mapshaper-file-import.js
@@ -15,6 +15,8 @@ api.importFiles = function(opts) {
stop('Missing input file(s)');
}
+ verbose("Importing: " + files.join(' '));
+
if (files.length == 1) {
dataset = api.importFile(files[0], opts);
} else if (opts.merge_files) {
@@ -30,71 +32,81 @@ api.importFiles = function(opts) {
};
api.importFile = function(path, opts) {
- var isBinary = internal.isBinaryFile(path),
- isShp = internal.guessInputFileType(path) == 'shp',
+ var fileType = internal.guessInputFileType(path),
input = {},
+ encoding = opts && opts.encoding || null,
cache = opts && opts.input || null,
cached = cache && (path in cache),
- type, content;
+ content;
cli.checkFileExists(path, cache);
- if (isShp && !cached) {
- content = null; // let ShpReader read the file (supports larger files)
- } else if (isBinary) {
+ if (fileType == 'shp' && !cached) {
+ // let ShpReader read the file (supports larger files)
+ content = null;
+
+ } else if (fileType == 'json' && !cached) {
+ // postpone reading of JSON files, to support incremental parsing
+ content = null;
+
+ } else if (fileType == 'text' && !cached) {
+ // content = cli.readFile(path); // read from buffer
+ content = null; // read from file, to support largest files (see mapshaper-delim-import.js)
+
+ } else if (fileType && internal.isSupportedBinaryInputType(path)) {
content = cli.readFile(path, null, cache);
- } else {
- content = cli.readFile(path, opts && opts.encoding || 'utf-8', cache);
- }
- type = internal.guessInputFileType(path) || internal.guessInputContentType(content);
- if (!type) {
- stop("Unable to import", path);
- } else if (type == 'json') {
- // parsing JSON here so input file can be gc'd before JSON data is imported
- // TODO: look into incrementally parsing JSON data
- try {
- // JSON data may already be parsed if imported via applyCommands()
- if (utils.isString(content)) {
- content = JSON.parse(content);
- }
- } catch(e) {
- stop("Unable to parse JSON");
+ if (utils.isString(content)) {
+ // Fix for issue #264 (applyCommands() input is file path instead of binary content)
+ stop('Expected binary content, received a string');
}
+
+ } else if (fileType) { // string type
+ content = cli.readFile(path, encoding || 'utf-8', cache);
+
+ } else { // type can't be inferred from filename -- try reading as text
+ content = cli.readFile(path, encoding || 'utf-8', cache);
+ fileType = internal.guessInputContentType(content);
+ if (fileType == 'text' && content.indexOf('\ufffd') > -1) {
+ // invalidate string data that contains the 'replacement character'
+ fileType = null;
+ }
+ }
+
+ if (!fileType) {
+ stop(internal.getUnsupportedFileMessage(path));
}
- input[type] = {filename: path, content: content};
+ input[fileType] = {filename: path, content: content};
content = null; // for g.c.
- if (type == 'shp' || type == 'dbf') {
+ if (fileType == 'shp' || fileType == 'dbf') {
internal.readShapefileAuxFiles(path, input, cache);
}
- if (type == 'shp' && !input.dbf) {
+ if (fileType == 'shp' && !input.dbf) {
message(utils.format("[%s] .dbf file is missing - shapes imported without attribute data.", path));
}
return internal.importContent(input, opts);
};
-/*
-api.importDataTable = function(path, opts) {
- // TODO: avoid the overhead of importing shape data, if present
- var dataset = api.importFile(path, opts);
- if (dataset.layers.length > 1) {
- // if multiple layers are imported (e.g. from multi-type GeoJSON), throw away
- // the geometry and merge them
- dataset.layers.forEach(function(lyr) {
- lyr.shapes = null;
- lyr.geometry_type = null;
- });
- dataset.layers = api.mergeLayers(dataset.layers);
+internal.getUnsupportedFileMessage = function(path) {
+ var ext = utils.getFileExtension(path);
+ var msg = 'Unable to import ' + path;
+ if (ext.toLowerCase() == 'zip') {
+ msg += ' (ZIP files must be unpacked before running mapshaper)';
+ } else {
+ msg += ' (unknown file type)';
}
- return dataset.layers[0].data;
+ return msg;
};
-*/
internal.readShapefileAuxFiles = function(path, obj, cache) {
var dbfPath = utils.replaceFileExtension(path, 'dbf');
+ var shxPath = utils.replaceFileExtension(path, 'shx');
var cpgPath = utils.replaceFileExtension(path, 'cpg');
var prjPath = utils.replaceFileExtension(path, 'prj');
if (cli.isFile(prjPath, cache)) {
obj.prj = {filename: prjPath, content: cli.readFile(prjPath, 'utf-8', cache)};
}
+ if (cli.isFile(shxPath, cache)) {
+ obj.shx = {filename: shxPath, content: cli.readFile(shxPath, null, cache)};
+ }
if (!obj.dbf && cli.isFile(dbfPath, cache)) {
obj.dbf = {filename: dbfPath, content: cli.readFile(dbfPath, null, cache)};
}
diff --git a/src/io/mapshaper-file-reader.js b/src/io/mapshaper-file-reader.js
new file mode 100644
index 000000000..edd9be9af
--- /dev/null
+++ b/src/io/mapshaper-file-reader.js
@@ -0,0 +1,144 @@
+/* @requires mapshaper-common, mapshaper-encodings */
+
+internal.FileReader = FileReader;
+internal.BufferReader = BufferReader;
+
+internal.readFirstChars = function(reader, n) {
+ return internal.bufferToString(reader.readSync(0, Math.min(n || 1000, reader.size())));
+};
+
+// Same interface as FileReader, for reading from a Buffer or ArrayBuffer instead of a file.
+function BufferReader(src) {
+ var bufSize = src.byteLength || src.length,
+ binArr, buf;
+
+ this.readToBinArray = function(start, length) {
+ if (bufSize < start + length) error("Out-of-range error");
+ if (!binArr) binArr = new BinArray(src);
+ binArr.position(start);
+ return binArr;
+ };
+
+ this.toString = function(enc) {
+ return internal.bufferToString(buffer(), enc);
+ };
+
+ this.readSync = function(start, length) {
+ // TODO: consider using a default length like FileReader
+ return buffer().slice(start, length || bufSize);
+ };
+
+ function buffer() {
+ if (!buf) {
+ buf = (src instanceof ArrayBuffer) ? utils.createBuffer(src) : src;
+ }
+ return buf;
+ }
+
+ this.findString = FileReader.prototype.findString;
+ this.expandBuffer = function() {return this;};
+ this.size = function() {return bufSize;};
+ this.close = function() {};
+}
+
+function FileReader(path, opts) {
+ var fs = require('fs'),
+ fileLen = fs.statSync(path).size,
+ DEFAULT_CACHE_LEN = opts && opts.cacheSize || 0x800000, // 8MB
+ DEFAULT_BUFFER_LEN = opts && opts.bufferSize || 0x4000, // 32K
+ fd, cacheOffs, cache, binArr;
+
+ internal.getStateVar('input_files').push(path); // bit of a kludge
+
+ this.expandBuffer = function() {
+ DEFAULT_BUFFER_LEN *= 2;
+ return this;
+ };
+
+ // Read to BinArray (for compatibility with ShpReader)
+ this.readToBinArray = function(start, length) {
+ if (updateCache(start, length)) {
+ binArr = new BinArray(cache);
+ }
+ binArr.position(start - cacheOffs);
+ return binArr;
+ };
+
+ // Read to Buffer
+ this.readSync = function(start, length) {
+ if (length > 0 === false) {
+ // use default (but variable) size if length is not specified
+ length = DEFAULT_BUFFER_LEN;
+ if (start + length > fileLen) {
+ length = fileLen - start; // truncate at eof
+ }
+ if (length === 0) {
+ return utils.createBuffer(0); // kludge to allow reading up to eof
+ }
+ }
+ updateCache(start, length);
+ return cache.slice(start - cacheOffs, start - cacheOffs + length);
+ };
+
+ this.size = function() {
+ return fileLen;
+ };
+
+ this.toString = function(enc) {
+ // TODO: use fd
+ return cli.readFile(path, enc || 'utf8');
+ };
+
+ this.close = function() {
+ if (fd) {
+ fs.closeSync(fd);
+ fd = null;
+ cache = null;
+ }
+ };
+
+ // Receive offset and length of byte string that must be read
+ // Return true if cache was updated, or false
+ function updateCache(fileOffs, bufLen) {
+ var headroom = fileLen - fileOffs,
+ bytesRead, bytesToRead;
+ if (headroom < bufLen || headroom < 0) {
+ error("Tried to read past end-of-file");
+ }
+ if (cache && fileOffs >= cacheOffs && cacheOffs + cache.length >= fileOffs + bufLen) {
+ return false;
+ }
+ bytesToRead = Math.max(DEFAULT_CACHE_LEN, bufLen);
+ if (headroom < bytesToRead) {
+ bytesToRead = headroom;
+ }
+ if (!cache || bytesToRead != cache.length) {
+ cache = utils.createBuffer(bytesToRead);
+ }
+ if (!fd) {
+ fd = fs.openSync(path, 'r');
+ }
+ bytesRead = fs.readSync(fd, cache, 0, bytesToRead, fileOffs);
+ cacheOffs = fileOffs;
+ if (bytesRead != bytesToRead) error("Error reading file");
+ return true;
+ }
+}
+
+FileReader.prototype.findString = function (str, maxLen) {
+ var len = Math.min(this.size(), maxLen || this.size());
+ var buf = this.readSync(0, len);
+ var strLen = str.length;
+ var n = buf.length - strLen;
+ var firstByte = str.charCodeAt(0);
+ var i;
+ for (i=0; i < n; i++) {
+ if (buf[i] == firstByte && buf.toString('utf8', i, i + strLen) == str) {
+ return {
+ offset: i + strLen,
+ text: buf.toString('utf8', 0, i)
+ };
+ }
+ }
+ return null;
+};
diff --git a/src/io/mapshaper-file-types.js b/src/io/mapshaper-file-types.js
index f812cb320..98984e6ca 100644
--- a/src/io/mapshaper-file-types.js
+++ b/src/io/mapshaper-file-types.js
@@ -4,10 +4,12 @@
internal.guessInputFileType = function(file) {
var ext = utils.getFileExtension(file || '').toLowerCase(),
type = null;
- if (ext == 'dbf' || ext == 'shp' || ext == 'prj') {
+ if (ext == 'dbf' || ext == 'shp' || ext == 'prj' || ext == 'shx') {
type = ext;
} else if (/json$/.test(ext)) {
type = 'json';
+ } else if (ext == 'csv' || ext == 'tsv' || ext == 'txt' || ext == 'tab') {
+ type = 'text';
}
return type;
};
@@ -83,9 +85,9 @@ internal.getFormatName = function(fmt) {
};
// Assumes file at @path is one of Mapshaper's supported file types
-internal.isBinaryFile = function(path) {
+internal.isSupportedBinaryInputType = function(path) {
var ext = utils.getFileExtension(path).toLowerCase();
- return ext == 'shp' || ext == 'dbf' || ext == 'zip'; // GUI accepts zip files
+ return ext == 'shp' || ext == 'shx' || ext == 'dbf'; // GUI also supports zip files
};
// Detect extensions of some unsupported file types, for cmd line validation
diff --git a/src/io/mapshaper-import.js b/src/io/mapshaper-import.js
index 3a9072ef0..e6e39ed87 100644
--- a/src/io/mapshaper-import.js
+++ b/src/io/mapshaper-import.js
@@ -4,6 +4,8 @@ mapshaper-geojson
mapshaper-topojson
mapshaper-shapefile
mapshaper-json-table
+mapshaper-json-import
+mapshaper-delim-import
*/
// Parse content of one or more input files and return a dataset
@@ -16,37 +18,31 @@ internal.importContent = function(obj, opts) {
var dataset, content, fileFmt, data;
opts = opts || {};
if (obj.json) {
- data = obj.json;
- content = data.content;
- if (utils.isString(content)) {
- try {
- content = JSON.parse(content);
- } catch(e) {
- stop("Unable to parse JSON");
- }
- }
- if (content.type == 'Topology') {
- fileFmt = 'topojson';
- dataset = internal.importTopoJSON(content, opts);
- } else if (content.type) {
- fileFmt = 'geojson';
- dataset = internal.importGeoJSON(content, opts);
- } else if (utils.isArray(content)) {
- fileFmt = 'json';
- dataset = internal.importJSONTable(content, opts);
- }
+ data = internal.importJSON(obj.json, opts);
+ fileFmt = data.format;
+ dataset = data.dataset;
+ internal.cleanPathsAfterImport(dataset, opts);
+
} else if (obj.text) {
fileFmt = 'dsv';
data = obj.text;
- dataset = internal.importDelim(data.content, opts);
+ dataset = internal.importDelim2(data, opts);
+
} else if (obj.shp) {
fileFmt = 'shapefile';
data = obj.shp;
dataset = internal.importShapefile(obj, opts);
+ internal.cleanPathsAfterImport(dataset, opts);
+
} else if (obj.dbf) {
fileFmt = 'dbf';
data = obj.dbf;
dataset = internal.importDbf(obj, opts);
+ } else if (obj.prj) {
+ // added for -proj command source
+ fileFmt = 'prj';
+ data = obj.prj;
+ dataset = {layers: [], info: {prj: data.content}};
}
if (!dataset) {
@@ -71,7 +67,6 @@ internal.importContent = function(obj, opts) {
dataset.info.input_files = [data.filename];
}
dataset.info.input_formats = [fileFmt];
-
return dataset;
};
@@ -83,9 +78,11 @@ internal.importFileContent = function(content, filename, opts) {
return internal.importContent(input, opts);
};
+
internal.importShapefile = function(obj, opts) {
- var shpSrc = obj.shp.content || obj.shp.filename, // content may be missing
- dataset = internal.importShp(shpSrc, opts),
+ var shpSrc = obj.shp.content || obj.shp.filename, // read from a file if (binary) content is missing
+ shxSrc = obj.shx ? obj.shx.content || obj.shx.filename : null,
+ dataset = internal.importShp(shpSrc, shxSrc, opts),
lyr = dataset.layers[0],
dbf;
if (obj.dbf) {
@@ -93,11 +90,11 @@ internal.importShapefile = function(obj, opts) {
utils.extend(dataset.info, dbf.info);
lyr.data = dbf.layers[0].data;
if (lyr.shapes && lyr.data.size() != lyr.shapes.length) {
- message("[shp] Mismatched .dbf and .shp record count -- possible data loss.");
+ message("Mismatched .dbf and .shp record count -- possible data loss.");
}
}
if (obj.prj) {
- dataset.info.input_prj = obj.prj.content;
+ dataset.info.prj = obj.prj.content;
}
return dataset;
};
diff --git a/src/io/mapshaper-json-import.js b/src/io/mapshaper-json-import.js
new file mode 100644
index 000000000..6d9c70b1b
--- /dev/null
+++ b/src/io/mapshaper-json-import.js
@@ -0,0 +1,126 @@
+/* @requires mapshaper-common */
+
+// Identify JSON type from the initial subset of a JSON string
+internal.identifyJSONString = function(str, opts) {
+ var maxChars = 1000;
+ var fmt = null;
+ if (str.length > maxChars) str = str.substr(0, maxChars);
+ str = str.replace(/\s/g, '');
+ if (opts && opts.json_path) {
+ fmt = 'json'; // TODO: make json_path compatible with other types
+ } else if (/^\[[{\]]/.test(str)) {
+ // empty array or array of objects
+ fmt = 'json';
+ } else if (/"arcs":\[|"objects":\{|"transform":\{/.test(str)) {
+ fmt = 'topojson';
+ } else if (/^\{"/.test(str)) {
+ fmt = 'geojson';
+ }
+ return fmt;
+};
+
+internal.identifyJSONObject = function(o) {
+ var fmt = null;
+ if (!o) {
+ //
+ } else if (o.type == 'Topology') {
+ fmt = 'topojson';
+ } else if (o.type) {
+ fmt = 'geojson';
+ } else if (utils.isArray(o)) {
+ fmt = 'json';
+ }
+ return fmt;
+};
+
+internal.importGeoJSONFile = function(fileReader, opts) {
+ var importer = new GeoJSONParser(opts);
+ new GeoJSONReader(fileReader).readObjects(importer.parseObject);
+ return importer.done();
+};
+
+internal.importJSONFile = function(reader, opts) {
+ var str = internal.readFirstChars(reader, 1000);
+ var type = internal.identifyJSONString(str, opts);
+ var dataset, retn;
+ if (type == 'geojson') { // consider only for larger files
+ dataset = internal.importGeoJSONFile(reader, opts);
+ retn = {
+ dataset: dataset,
+ format: 'geojson'
+ };
+ } else {
+ retn = {
+ // content: cli.readFile(path, 'utf8')}
+ content: reader.toString('utf8')
+ };
+ }
+ reader.close();
+ return retn;
+};
+
+internal.importJSON = function(data, opts) {
+ var content = data.content,
+ filename = data.filename,
+ retn = {filename: filename},
+ reader;
+
+ if (!content) {
+ reader = new FileReader(filename);
+ } else if (content instanceof ArrayBuffer) {
+ // Web API imports JSON as ArrayBuffer, to support larger files
+ if (content.byteLength < 1e7) {
+ // content = utils.createBuffer(content).toString();
+ content = internal.bufferToString(utils.createBuffer(content));
+ } else {
+ reader = new BufferReader(content);
+ content = null;
+ }
+ }
+
+ if (reader) {
+ data = internal.importJSONFile(reader, opts);
+ if (data.dataset) {
+ retn.dataset = data.dataset;
+ retn.format = data.format;
+ } else {
+ content = data.content;
+ }
+ }
+
+ if (content) {
+ if (utils.isString(content)) {
+ try {
+ content = JSON.parse(content); // ~3sec for 100MB string
+ } catch(e) {
+ stop("Unable to parse JSON");
+ }
+ }
+ if (opts.json_path) {
+ content = internal.selectFromObject(content, opts.json_path);
+ }
+ retn.format = internal.identifyJSONObject(content, opts);
+ if (retn.format == 'topojson') {
+ retn.dataset = internal.importTopoJSON(content, opts);
+ } else if (retn.format == 'geojson') {
+ retn.dataset = internal.importGeoJSON(content, opts);
+ } else if (retn.format == 'json') {
+ retn.dataset = internal.importJSONTable(content, opts);
+ } else {
+ stop("Unknown JSON format");
+ }
+ }
+
+ return retn;
+};
+
+// path: path from top-level to the target object, as a list of property
+// names separated by '.'
+internal.selectFromObject = function(o, path) {
+ var parts = path.split('.');
+ var value = o && o[parts[0]];
+ if (parts > 1) {
+ return internal.selectFromObject(value, parts.slice(1).join(''));
+ }
+ return value;
+};
diff --git a/src/mapshaper-common.js b/src/mapshaper-common.js
index ae2bb92da..a1bffeb5b 100644
--- a/src/mapshaper-common.js
+++ b/src/mapshaper-common.js
@@ -1,20 +1,74 @@
-/* @requires mapshaper-utils */
+/* @requires mapshaper-utils, mapshaper-buffer */
var api = {};
+var VERSION; // set by build script
var internal = {
VERSION: VERSION, // export version
LOGGING: false,
- QUIET: false,
- TRACING: false,
- VERBOSE: false,
- T: T,
- defs: {}
+ context: createContext()
+};
+
+// Support for timing using T.start() and T.stop("message")
+var T = {
+ stack: [],
+ start: function() {
+ T.stack.push(+new Date());
+ },
+ stop: function(note) {
+ var elapsed = (+new Date() - T.stack.pop());
+ var msg = elapsed + 'ms';
+ if (note) {
+ msg = note + " " + msg;
+ }
+ verbose(msg);
+ return elapsed;
+ }
};
new Float64Array(1); // workaround for https://github.com/nodejs/node/issues/6006
-// in case running in browser and loading browserified modules separately
-var Buffer = require('buffer').Buffer;
+internal.runningInBrowser = function() {return !!api.gui;};
+
+internal.getStateVar = function(key) {
+ return internal.context[key];
+};
+
+internal.setStateVar = function(key, val) {
+ internal.context[key] = val;
+};
+
+function createContext() {
+ return {
+ DEBUG: false,
+ QUIET: false,
+ VERBOSE: false,
+ defs: {},
+ input_files: []
+ };
+}
+
+// Install a new set of context variables, clear them when an async callback is called.
+// @cb callback function to wrap
+// returns wrapped callback function
+function createAsyncContext(cb) {
+ internal.context = createContext();
+ return function() {
+ cb.apply(null, utils.toArray(arguments));
+ // clear context after cb(), so output/errors can be handled in current context
+ internal.context = createContext();
+ };
+}
+
+// Save the current context, restore it when an async callback is called
+// @cb callback function to wrap
+// returns wrapped callback function
+function preserveContext(cb) {
+ var ctx = internal.context;
+ return function() {
+ internal.context = ctx;
+ cb.apply(null, utils.toArray(arguments));
+ };
+}
function error() {
internal.error.apply(null, utils.toArray(arguments));
@@ -25,24 +79,34 @@ function stop() {
internal.stop.apply(null, utils.toArray(arguments));
}
-function APIError(msg) {
+function UserError(msg) {
var err = new Error(msg);
- err.name = 'APIError';
+ err.name = 'UserError';
return err;
}
+function messageArgs(args) {
+ var arr = utils.toArray(args);
+ var cmd = internal.getStateVar('current_command');
+ if (cmd && cmd != 'help') {
+ arr.unshift('[' + cmd + ']');
+ }
+ return arr;
+}
+
function message() {
- internal.message.apply(null, utils.toArray(arguments));
+ internal.message.apply(null, messageArgs(arguments));
}
function verbose() {
- if (internal.VERBOSE) {
- internal.logArgs(arguments);
+ if (internal.getStateVar('VERBOSE')) {
+ // internal.logArgs(arguments);
+ internal.message.apply(null, messageArgs(arguments));
}
}
-function trace() {
- if (internal.TRACING) {
+function debug() {
+ if (internal.getStateVar('DEBUG')) {
internal.logArgs(arguments);
}
}
@@ -59,27 +123,28 @@ api.enableLogging = function() {
api.printError = function(err) {
var msg;
if (utils.isString(err)) {
- err = new APIError(err);
+ err = new UserError(err);
}
- if (internal.LOGGING && err.name == 'APIError') {
+ if (internal.LOGGING && err.name == 'UserError') {
msg = err.message;
if (!/Error/.test(msg)) {
msg = "Error: " + msg;
}
- console.error(msg);
- message("Run mapshaper -h to view help");
+ console.error(messageArgs([msg]).join(' '));
+ internal.message("Run mapshaper -h to view help");
} else {
+ // not a user error or logging is disabled -- throw it
throw err;
}
};
internal.error = function() {
- var msg = Utils.toArray(arguments).join(' ');
+ var msg = utils.toArray(arguments).join(' ');
throw new Error(msg);
};
internal.stop = function() {
- throw new APIError(internal.formatLogArgs(arguments));
+ throw new UserError(internal.formatLogArgs(arguments));
};
internal.message = function() {
@@ -109,7 +174,7 @@ internal.formatStringsAsGrid = function(arr) {
};
internal.logArgs = function(args) {
- if (internal.LOGGING && !internal.QUIET && utils.isArrayLike(args)) {
+ if (internal.LOGGING && !internal.getStateVar('QUIET') && utils.isArrayLike(args)) {
(console.error || console.log).call(console, internal.formatLogArgs(args));
}
};
@@ -125,6 +190,12 @@ internal.probablyDecimalDegreeBounds = function(b) {
return containsBounds(world, bbox);
};
+internal.clampToWorldBounds = function(b) {
+ var bbox = (b instanceof Bounds) ? b.toArray() : b;
+ return new Bounds().setBounds(Math.max(bbox[0], -180), Math.max(bbox[1], -90),
+ Math.min(bbox[2], 180), Math.min(bbox[3], 90));
+};
+
internal.layerHasGeometry = function(lyr) {
return internal.layerHasPaths(lyr) || internal.layerHasPoints(lyr);
};
@@ -144,23 +215,44 @@ internal.layerHasNonNullShapes = function(lyr) {
});
};
-internal.requireDataFields = function(table, fields, cmd) {
- var prefix = cmd ? '[' + cmd + '] ' : '';
+internal.requireDataFields = function(table, fields) {
if (!table) {
- stop(prefix + "Missing attribute data");
+ stop("Missing attribute data");
}
var dataFields = table.getFields(),
missingFields = utils.difference(fields, dataFields);
if (missingFields.length > 0) {
- stop(prefix + "Table is missing one or more fields:\n",
+ stop("Table is missing one or more fields:\n",
missingFields, "\nExisting fields:", '\n' + internal.formatStringsAsGrid(dataFields));
}
};
+internal.layerTypeMessage = function(lyr, defaultMsg, customMsg) {
+ var msg;
+ if (customMsg && utils.isString(customMsg)) {
+ msg = customMsg;
+ } else {
+ msg = defaultMsg + ', ';
+ if (!lyr || !lyr.geometry_type) {
+ msg += 'received a layer with no geometry';
+ } else {
+ msg += 'received a ' + lyr.geometry_type + ' layer';
+ }
+ }
+ return msg;
+};
+
+internal.requirePolylineLayer = function(lyr, msg) {
+ if (!lyr || lyr.geometry_type !== 'polyline')
+ stop(internal.layerTypeMessage(lyr, "Expected a polyline layer", msg));
+};
+
internal.requirePolygonLayer = function(lyr, msg) {
- if (!lyr || lyr.geometry_type !== 'polygon') stop(msg || "Expected a polygon layer");
+ if (!lyr || lyr.geometry_type !== 'polygon')
+ stop(internal.layerTypeMessage(lyr, "Expected a polygon layer", msg));
};
internal.requirePathLayer = function(lyr, msg) {
- if (!lyr || !internal.layerHasPaths(lyr)) stop(msg || "Expected a polygon or polyline layer");
+ if (!lyr || !internal.layerHasPaths(lyr))
+ stop(internal.layerTypeMessage(lyr, "Expected a polygon or polyline layer", msg));
};
diff --git a/src/mapshaper.js b/src/mapshaper.js
index 2c0f00943..b42ff7fe6 100644
--- a/src/mapshaper.js
+++ b/src/mapshaper.js
@@ -1,12 +1,13 @@
/* @requires
mapshaper-commands
+mapshaper-cli-utils
*/
api.cli = cli;
api.internal = internal;
api.utils = utils;
api.geom = geom;
-this.mapshaper = api;
+mapshaper = api;
// Expose internal objects for testing
utils.extend(api.internal, {
@@ -22,6 +23,7 @@ utils.extend(api.internal, {
DbfReader: DbfReader,
ShapefileTable: ShapefileTable,
ArcCollection: ArcCollection,
+ PointIter: PointIter,
ArcIter: ArcIter,
ShapeIter: ShapeIter,
Bounds: Bounds,
@@ -32,7 +34,7 @@ utils.extend(api.internal, {
topojson: TopoJSON,
geojson: GeoJSON,
svg: SVG,
- APIError: APIError
+ UserError: UserError
});
if (typeof define === "function" && define.amd) {
diff --git a/src/paths/mapshaper-arc-dissolve.js b/src/paths/mapshaper-arc-dissolve.js
index ff6e2dbbd..6d286a5a3 100644
--- a/src/paths/mapshaper-arc-dissolve.js
+++ b/src/paths/mapshaper-arc-dissolve.js
@@ -1,12 +1,13 @@
/* @requires
mapshaper-nodes
mapshaper-path-endpoints
+mapshaper-path-utils
mapshaper-dataset-utils
*/
// Dissolve arcs that can be merged without affecting topology of layers
// remove arcs that are not referenced by any layer; remap arc ids
-// in layers. (In-place).
+// in layers. (dataset.arcs is replaced).
internal.dissolveArcs = function(dataset) {
var arcs = dataset.arcs,
layers = dataset.layers.filter(internal.layerHasPaths);
@@ -26,7 +27,7 @@ internal.dissolveArcs = function(dataset) {
// modify copies of the original shapes; original shapes should be unmodified
// (need to test this)
lyr.shapes = lyr.shapes.map(function(shape) {
- return internal.editPaths(shape && shape.concat(), translatePath);
+ return internal.editShapeParts(shape && shape.concat(), translatePath);
});
});
dataset.arcs = internal.dissolveArcCollection(arcs, newArcs, totalPoints);
@@ -119,7 +120,7 @@ internal.dissolveArcCollection = function(arcs, newArcs, newLen) {
// Test whether two arcs can be merged together
internal.getArcDissolveTest = function(layers, arcs) {
- var nodes = internal.getFilteredNodeCollection(layers, arcs),
+ var nodes = new NodeCollection(arcs, internal.getArcPresenceTest2(layers, arcs)),
// don't allow dissolving through endpoints of polyline paths
lineLayers = layers.filter(function(lyr) {return lyr.geometry_type == 'polyline';}),
testLineEndpoint = internal.getPathEndpointTest(lineLayers, arcs),
@@ -140,19 +141,3 @@ internal.getArcDissolveTest = function(layers, arcs) {
lastId = arcId;
}
};
-
-internal.getFilteredNodeCollection = function(layers, arcs) {
- var counts = internal.countArcReferences(layers, arcs),
- test = function(arcId) {
- return counts[absArcId(arcId)] > 0;
- };
- return new NodeCollection(arcs, test);
-};
-
-internal.countArcReferences = function(layers, arcs) {
- var counts = new Uint32Array(arcs.size());
- layers.forEach(function(lyr) {
- internal.countArcsInShapes(lyr.shapes, counts);
- });
- return counts;
-};
diff --git a/src/paths/mapshaper-arc-flags.js b/src/paths/mapshaper-arc-flags.js
new file mode 100644
index 000000000..035c1659a
--- /dev/null
+++ b/src/paths/mapshaper-arc-flags.js
@@ -0,0 +1,25 @@
+
+function ArcFlags(size) {
+ var fwd = new Int8Array(size),
+ rev = new Int8Array(size);
+
+ this.get = function(arcId) {
+ return arcId < 0 ? rev[~arcId] : fwd[arcId];
+ };
+
+ this.set = function(arcId, val) {
+ if (arcId < 0) {
+ rev[~arcId] = val;
+ } else {
+ fwd[arcId] = val;
+ }
+ };
+
+ this.setUsed = function(arcId) {
+ this.set(arcId, 1);
+ };
+
+ this.isUsed = function(arcId) {
+ return this.get(arcId) !== 0;
+ };
+}
diff --git a/src/paths/mapshaper-arcs.js b/src/paths/mapshaper-arcs.js
index 272b40f96..2a0df05b6 100644
--- a/src/paths/mapshaper-arcs.js
+++ b/src/paths/mapshaper-arcs.js
@@ -130,7 +130,10 @@ function ArcCollection() {
this.getCopy = function() {
var copy = new ArcCollection(new Int32Array(_nn), new Float64Array(_xx),
new Float64Array(_yy));
- if (_zz) copy.setThresholds(new Float64Array(_zz));
+ if (_zz) {
+ copy.setThresholds(new Float64Array(_zz));
+ copy.setRetainedInterval(_zlimit);
+ }
return copy;
};
@@ -211,12 +214,13 @@ function ArcCollection() {
step = fw ? 1 : -1,
v1 = fw ? _ii[absId] : _ii[absId] + n - 1,
v2 = v1,
+ xx = _xx, yy = _yy, zz = _zz,
count = 0;
for (var j = 1; j < n; j++) {
v2 += step;
- if (zlim === 0 || _zz[v2] >= zlim) {
- cb(v1, v2, _xx, _yy);
+ if (zlim === 0 || zz[v2] >= zlim) {
+ cb(v1, v2, xx, yy);
v1 = v2;
count++;
}
@@ -283,26 +287,29 @@ function ArcCollection() {
// Return null if no arcs were re-indexed (and no arcs were removed)
//
this.filter = function(cb) {
- var map = new Int32Array(this.size()),
+ var test = function(i) {
+ return cb(this.getArcIter(i), i);
+ }.bind(this);
+ return this.deleteArcs(test);
+ };
+
+ this.deleteArcs = function(test) {
+ var n = this.size(),
+ map = new Int32Array(n),
goodArcs = 0,
goodPoints = 0;
- for (var i=0, n=this.size(); i 0) {
- arr = this.getRemovableThresholds();
+ arr = this.getRemovableThresholds(nth);
rank = utils.findRankByValue(arr, val);
pct = arr.length > 0 ? 1 - (rank - 1) / arr.length : 1;
} else {
@@ -529,23 +538,9 @@ function ArcCollection() {
return pct;
};
- this.getThresholdByPct = function(pct) {
- var tmp = this.getRemovableThresholds(),
- rank, z;
- if (tmp.length === 0) { // No removable points
- rank = 0;
- } else {
- rank = Math.floor((1 - pct) * (tmp.length + 2));
- }
-
- if (rank <= 0) {
- z = 0;
- } else if (rank > tmp.length) {
- z = Infinity;
- } else {
- z = utils.findValueByRank(tmp, rank);
- }
- return z;
+ // nth (optional): sample every nth threshold (use estimate for speed)
+ this.getThresholdByPct = function(pct, nth) {
+ return internal.getThresholdByPct(pct, this, nth);
};
this.arcIntersectsBBox = function(i, b1) {
@@ -579,6 +574,8 @@ function ArcCollection() {
return _xx && _xx.length || 0;
};
+ this.getFilteredPointCount = getFilteredPointCount;
+
this.getBounds = function() {
return _allBounds.clone();
};
@@ -609,6 +606,7 @@ function ArcCollection() {
return bbox;
};
+ // TODO: move this and similar methods out of ArcCollection
this.getMultiShapeBounds = function(shapeIds, bounds) {
bounds = bounds || new Bounds();
if (shapeIds) { // handle null shapes
diff --git a/src/paths/mapshaper-path-division.js b/src/paths/mapshaper-path-division.js
index 806ea175d..b2b34204d 100644
--- a/src/paths/mapshaper-path-division.js
+++ b/src/paths/mapshaper-path-division.js
@@ -3,6 +3,7 @@ mapshaper-segment-intersection,
mapshaper-dataset-utils,
mapshaper-path-index
mapshaper-polygon-repair
+mapshaper-units
*/
// Functions for dividing polygons and polygons at points where arc-segments intersect
@@ -16,34 +17,136 @@ mapshaper-polygon-repair
// Divide a collection of arcs at points where segments intersect
// and re-index the paths of all the layers that reference the arc collection.
// (in-place)
-internal.addIntersectionCuts = function(dataset, opts) {
+internal.addIntersectionCuts = function(dataset, _opts) {
+ var opts = _opts || {};
var arcs = dataset.arcs;
- var snapDist = internal.getHighPrecisionSnapInterval(arcs);
- var snapCount = opts && opts.no_snap ? 0 : internal.snapCoordsByInterval(arcs, snapDist);
- var dupeCount = arcs.dedupCoords();
+ var snapDist, snapCount, dupeCount, nodes;
+ if (opts.snap_interval) {
+ snapDist = internal.convertIntervalParam(opts.snap_interval, internal.getDatasetCRS(dataset));
+ } else {
+ snapDist = internal.getHighPrecisionSnapInterval(arcs);
+ }
+ debug('addIntersectionCuts() snap dist:', snapDist);
+
+ // bake-in any simplification (bug fix; before, -simplify followed by dissolve2
+ // used to reset simplification)
+ arcs.flatten();
+ snapCount = opts.no_snap ? 0 : internal.snapCoordsByInterval(arcs, snapDist);
+ dupeCount = arcs.dedupCoords();
if (snapCount > 0 || dupeCount > 0) {
// Detect topology again if coordinates have changed
api.buildTopology(dataset);
}
// cut arcs at points where segments intersect
- var map = internal.divideArcs(arcs);
-
- // update arc ids in arc-based layers and clean up arc geometry
- // to remove degenerate arcs and duplicate points
- var nodes = new NodeCollection(arcs);
+ internal.cutPathsAtIntersections(dataset);
+ // Clean shapes by removing collapsed arc references, etc.
+ // TODO: consider alternative -- avoid creating degenerate arcs
+ // in insertCutPoints()
dataset.layers.forEach(function(lyr) {
if (internal.layerHasPaths(lyr)) {
- internal.updateArcIds(lyr.shapes, map, nodes);
- // Clean shapes by removing collapsed arc references, etc.
- // TODO: consider alternative -- avoid creating degenerate arcs
- // in insertCutPoints()
internal.cleanShapes(lyr.shapes, arcs, lyr.geometry_type);
}
});
+ // Further clean-up -- remove duplicate and missing arcs
+ nodes = internal.cleanArcReferences(dataset);
+
return nodes;
};
+// Remap any references to duplicate arcs in paths to use the same arcs
+// Remove any unused arcs from the dataset's ArcCollection.
+// Return a NodeCollection
+internal.cleanArcReferences = function(dataset) {
+ var nodes = new NodeCollection(dataset.arcs);
+ var map = internal.findDuplicateArcs(nodes);
+ var dropCount;
+ if (map) {
+ internal.replaceIndexedArcIds(dataset, map);
+ }
+ dropCount = internal.deleteUnusedArcs(dataset);
+ if (dropCount > 0) {
+ // rebuild nodes if arcs have changed
+ nodes = new NodeCollection(dataset.arcs);
+ }
+ return nodes;
+};
+
+
+// @map an Object mapping old to new ids
+internal.replaceIndexedArcIds = function(dataset, map) {
+ var remapPath = function(ids) {
+ var arcId, absId, id2;
+ for (var i=0; i 0 ? map : null;
+};
+
+internal.deleteUnusedArcs = function(dataset) {
+ var test = internal.getArcPresenceTest2(dataset.layers, dataset.arcs);
+ var count1 = dataset.arcs.size();
+ var map = dataset.arcs.deleteArcs(test); // condenses arcs
+ var count2 = dataset.arcs.size();
+ var deleteCount = count1 - count2;
+ if (deleteCount > 0) {
+ internal.replaceIndexedArcIds(dataset, map);
+ }
+ return deleteCount;
+};
+
+// Return a function for updating a path (array of arc ids)
+// @map array generated by insertCutPoints()
+// @arcCount number of arcs in divided collection (kludge)
+internal.getDividedArcUpdater = function(map, arcCount) {
+ return function(ids) {
+ var ids2 = [];
+ for (var j=0; j= map.length - 1 ? arcCount : map[absId + 1]) - 1,
+ id2;
+ do {
+ if (rev) {
+ id2 = ~max;
+ max--;
+ } else {
+ id2 = min;
+ min++;
+ }
+ ids.push(id2);
+ } while (max - min >= 0);
+ }
+};
+
// Divides a collection of arcs at points where arc paths cross each other
// Returns array for remapping arc ids
internal.divideArcs = function(arcs) {
@@ -51,10 +154,25 @@ internal.divideArcs = function(arcs) {
// TODO: avoid the following if no points need to be added
var map = internal.insertCutPoints(points, arcs);
// segment-point intersections currently create duplicate points
+ // TODO: consider dedup in a later cleanup pass?
arcs.dedupCoords();
return map;
};
+internal.cutPathsAtIntersections = function(dataset) {
+ var map = internal.divideArcs(dataset.arcs);
+ internal.remapDividedArcs(dataset, map);
+};
+
+internal.remapDividedArcs = function(dataset, map) {
+ var remapPath = internal.getDividedArcUpdater(map, dataset.arcs.size());
+ dataset.layers.forEach(function(lyr) {
+ if (internal.layerHasPaths(lyr)) {
+ internal.editShapes(lyr.shapes, remapPath);
+ }
+ });
+};
+
// Inserts array of cutting points into an ArcCollection
// Returns array for remapping arc ids
internal.insertCutPoints = function(unfilteredPoints, arcs) {
@@ -73,7 +191,9 @@ internal.insertCutPoints = function(unfilteredPoints, arcs) {
yy1 = new Float64Array(destPointTotal),
n0, n1, arcLen, p;
+
points.reverse(); // reverse sorted order to use pop()
+
p = points.pop();
for (var srcArcId=0, destArcId=0; srcArcId < srcArcTotal; srcArcId++) {
@@ -135,9 +255,15 @@ internal.getCutPoint = function(x, y, i, j, xx, yy) {
}
if (geom.outsideRange(x, ix, jx) || geom.outsideRange(y, iy, jy)) {
// out-of-range issues should have been handled upstream
- trace("[getCutPoint()] Coordinate range error");
+ debug("[getCutPoint()] Coordinate range error");
return null;
}
+ // if (x == ix && y == iy || x == jx && y == jy) {
+ // if point xy is at a vertex, don't insert a (duplicate) point
+ // TODO: investigate why this can cause pathfinding errors
+ // e.g. when clipping cd115_districts
+ // return null;
+ // }
return {x: x, y: y, i: i};
};
@@ -186,46 +312,3 @@ internal.findClippingPoints = function(arcs) {
data = arcs.getVertexData();
return internal.convertIntersectionsToCutPoints(intersections, data.xx, data.yy);
};
-
-// Updates arc ids in @shapes array using @map object
-// ... also, removes references to duplicate arcs
-internal.updateArcIds = function(shapes, map, nodes) {
- var arcCount = nodes.arcs.size(),
- shape2;
- for (var i=0; i= map.length - 1 ? arcCount : map[absId + 1]) - 1,
- id2;
- do {
- if (rev) {
- id2 = ~max;
- max--;
- } else {
- id2 = min;
- min++;
- }
- // If there are duplicate arcs, switch to the same one
- if (nodes) {
- id2 = nodes.findMatchingArc(id2);
- }
- ids.push(id2);
- } while (max - min >= 0);
- }
-};
diff --git a/src/paths/mapshaper-path-endpoints.js b/src/paths/mapshaper-path-endpoints.js
index 5891c39a0..0c2ad5ff9 100644
--- a/src/paths/mapshaper-path-endpoints.js
+++ b/src/paths/mapshaper-path-endpoints.js
@@ -10,7 +10,7 @@ internal.getPathEndpointTest = function(layers, arcs) {
});
function addShape(shape) {
- internal.forEachPath(shape, addPath);
+ internal.forEachShapePart(shape, addPath);
}
function addPath(path) {
diff --git a/src/paths/mapshaper-path-filters.js b/src/paths/mapshaper-path-filters.js
new file mode 100644
index 000000000..542eabf2e
--- /dev/null
+++ b/src/paths/mapshaper-path-filters.js
@@ -0,0 +1,45 @@
+/* @requires mapshaper-shape-geom */
+
+internal.getVertexCountTest = function(minVertices, arcs) {
+ return function(path) {
+ // first and last vertex in ring count as one
+ return geom.countVerticesInPath(path, arcs) <= minVertices;
+ };
+};
+
+internal.getSliverTest = function(arcs) {
+ var maxSliverArea = internal.calcMaxSliverArea(arcs);
+ return function(path) {
+ // TODO: more sophisticated metric, perhaps considering shape
+ var area = geom.getPlanarPathArea(path, arcs);
+ return Math.abs(area) <= maxSliverArea;
+ };
+};
+
+internal.getMinAreaTest = function(areaParam, dataset, opts) {
+ var arcs = dataset.arcs;
+ var minArea = internal.convertAreaParam(areaParam, internal.getDatasetCRS(dataset));
+ if (opts && opts.weighted) {
+ return internal.getWeightedMinAreaFilter(minArea, dataset.arcs);
+ }
+ return internal.getMinAreaFilter(minArea, dataset.arcs);
+};
+
+internal.getMinAreaFilter = function(minArea, arcs) {
+ var pathArea = arcs.isPlanar() ? geom.getPlanarPathArea : geom.getSphericalPathArea;
+ return function(path) {
+ var area = pathArea(path, arcs);
+ return Math.abs(area) < minArea;
+ };
+};
+
+internal.getWeightedMinAreaFilter = function(minArea, arcs) {
+ var pathArea = arcs.isPlanar() ? geom.getPlanarPathArea : geom.getSphericalPathArea;
+ var pathPerimeter = arcs.isPlanar() ? geom.getPlanarPathPerimeter : geom.getSphericalPathPerimeter;
+ return function(path) {
+ var area = pathArea(path, arcs);
+ var perim = pathPerimeter(path, arcs);
+ var compactness = geom.calcPolsbyPopperCompactness(area, perim);
+ return Math.abs(area * compactness) < minArea;
+ };
+};
diff --git a/src/paths/mapshaper-path-import.js b/src/paths/mapshaper-path-import.js
index adcc5d365..4ec6611d2 100644
--- a/src/paths/mapshaper-path-import.js
+++ b/src/paths/mapshaper-path-import.js
@@ -4,8 +4,30 @@ mapshaper-shape-geom,
mapshaper-snapping,
mapshaper-shape-utils,
mapshaper-polygon-repair
+mapshaper-units
*/
+// Apply snapping, remove duplicate coords and clean up defective paths in a dataset
+// Assumes that any CRS info has been added to the dataset
+// @opts: import options
+internal.cleanPathsAfterImport = function(dataset, opts) {
+ var arcs = dataset.arcs;
+ var snapDist;
+ if (opts.snap || opts.auto_snap || opts.snap_interval) { // auto_snap is older name
+ if (opts.snap_interval) {
+ snapDist = internal.convertIntervalParam(opts.snap_interval, internal.getDatasetCRS(dataset));
+ }
+ if (arcs) {
+ internal.snapCoords(arcs, snapDist);
+ }
+ }
+ dataset.layers.forEach(function(lyr) {
+ if (internal.layerHasPaths(lyr)) {
+ internal.cleanShapes(lyr.shapes, arcs, lyr.geometry_type);
+ }
+ });
+};
+
// Accumulates points in buffers until #endPath() is called
// @drain callback: function(xarr, yarr, size) {}
//
@@ -108,6 +130,7 @@ function PathImporter(opts) {
var arcs;
var layers;
var lyr = {name: ''};
+ var snapDist;
if (dupeCount > 0) {
verbose(utils.format("Removed %,d duplicate point%s", dupeCount, utils.pluralSuffix(dupeCount)));
@@ -122,9 +145,9 @@ function PathImporter(opts) {
}
arcs = new ArcCollection(nn, xx, yy);
- if (opts.auto_snap || opts.snap_interval) {
- internal.snapCoords(arcs, opts.snap_interval);
- }
+ //if (opts.snap || opts.auto_snap || opts.snap_interval) { // auto_snap is older name
+ // internal.snapCoords(arcs, opts.snap_interval);
+ //}
}
if (collectionType == 'mixed') {
@@ -142,9 +165,9 @@ function PathImporter(opts) {
}
layers.forEach(function(lyr) {
- if (internal.layerHasPaths(lyr)) {
- internal.cleanShapes(lyr.shapes, arcs, lyr.geometry_type);
- }
+ //if (internal.layerHasPaths(lyr)) {
+ //internal.cleanShapes(lyr.shapes, arcs, lyr.geometry_type);
+ //}
if (lyr.data) {
internal.fixInconsistentFields(lyr.data.getRecords());
}
diff --git a/src/paths/mapshaper-path-index.js b/src/paths/mapshaper-path-index.js
index 8c41659d3..34c961f6d 100644
--- a/src/paths/mapshaper-path-index.js
+++ b/src/paths/mapshaper-path-index.js
@@ -3,58 +3,87 @@ mapshaper-shape-utils
mapshaper-dataset-utils
mapshaper-shape-geom
mapshaper-polygon-index
+mapshaper-bounds-search
*/
function PathIndex(shapes, arcs) {
- var _index;
- // var totalArea = arcs.getBounds().area();
+ var boundsQuery = internal.getBoundsSearchFunction(getRingData(shapes, arcs));
var totalArea = internal.getPathBounds(shapes, arcs).area();
- init(shapes);
-
- function init(shapes) {
- var boxes = [];
+ function getRingData(shapes, arcs) {
+ var arr = [];
shapes.forEach(function(shp, shpId) {
var n = shp ? shp.length : 0;
for (var i=0; i totalArea * 0.02) {
- bbox.index = new PolygonIndex([ids], arcs);
+ // Returns shape ids of all polygons that intersect point p
+ // (p is inside a ring or on the boundary)
+ this.findEnclosingShapes = function(p) {
+ var ids = [];
+ var groups = groupItemsByShapeId(findPointHitCandidates(p));
+ groups.forEach(function(group) {
+ if (testPointInRings(p, group)) {
+ ids.push(group[0].id);
}
- }
- }
+ });
+ return ids;
+ };
+ // Returns shape id of a polygon that intersects p or -1
+ // (If multiple intersections, returns on of the polygons)
this.findEnclosingShape = function(p) {
var shpId = -1;
- var shapes = findPointHitShapes(p);
- shapes.forEach(function(paths) {
- if (testPointInRings(p, paths)) {
- shpId = paths[0].id;
+ var groups = groupItemsByShapeId(findPointHitCandidates(p));
+ groups.forEach(function(group) {
+ if (testPointInRings(p, group)) {
+ shpId = group[0].id;
}
});
return shpId;
};
+ this.findPointEnclosureCandidates = function(p, buffer) {
+ var items = findPointHitCandidates(p, buffer);
+ return utils.pluck(items, 'id');
+ };
+
this.pointIsEnclosed = function(p) {
- return testPointInRings(p, findPointHitRings(p));
+ return testPointInRings(p, findPointHitCandidates(p));
+ };
+
+ // Finds the polygon containing the smallest ring that entirely contains @ring
+ // Assumes ring boundaries do not cross.
+ // Unhandled edge case:
+ // two rings share at least one segment but are not congruent.
+ // @ring: array of arc ids
+ // Returns id of enclosing polygon or -1 if none found
+ this.findSmallestEnclosingPolygon = function(ring) {
+ var bounds = arcs.getSimpleShapeBounds(ring);
+ var p = getTestPoint(ring);
+ var smallest;
+ findPointHitCandidates(p).forEach(function(cand) {
+ if (cand.bounds.contains(bounds) && // skip partially intersecting bboxes (can't be enclosures)
+ !cand.bounds.sameBounds(bounds) && // skip self, congruent and reversed-congruent rings
+ !(smallest && smallest.bounds.area() < cand.bounds.area()) &&
+ testPointInRing(p, cand)) {
+ smallest = cand;
+ }
+ });
+
+ return smallest ? smallest.id : -1;
};
this.arcIsEnclosed = function(arcId) {
- return this.pointIsEnclosed(getTestPoint(arcId));
+ return this.pointIsEnclosed(getTestPoint([arcId]));
};
// Test if a polygon ring is contained within an indexed ring
@@ -64,28 +93,24 @@ function PathIndex(shapes, arcs) {
// been detected previously).
//
this.pathIsEnclosed = function(pathIds) {
- var arcId = pathIds[0];
- var p = getTestPoint(arcId);
- return this.pointIsEnclosed(p);
+ return this.pointIsEnclosed(getTestPoint(pathIds));
};
// return array of paths that are contained within a path, or null if none
// @pathIds Array of arc ids comprising a closed path
this.findEnclosedPaths = function(pathIds) {
- var pathBounds = arcs.getSimpleShapeBounds(pathIds),
- cands = _index.search(pathBounds.toArray()),
+ var b = arcs.getSimpleShapeBounds(pathIds),
+ cands = boundsQuery(b.xmin, b.ymin, b.xmax, b.ymax),
paths = [],
index;
if (cands.length > 6) {
index = new PolygonIndex([pathIds], arcs);
}
-
-
cands.forEach(function(cand) {
- var p = getTestPoint(cand.ids[0]);
- var isEnclosed = index ?
- index.pointInPolygon(p[0], p[1]) : pathContainsPoint(pathIds, pathBounds, p);
+ var p = getTestPoint(cand.ids);
+ var isEnclosed = b.containsPoint(p[0], p[1]) && (index ?
+ index.pointInPolygon(p[0], p[1]) : geom.testPointInRing(p[0], p[1], pathIds, arcs));
if (isEnclosed) {
paths.push(cand.ids);
}
@@ -104,13 +129,24 @@ function PathIndex(shapes, arcs) {
return paths.length > 0 ? paths : null;
};
+ function testPointInRing(p, cand) {
+ if (!cand.bounds.containsPoint(p[0], p[1])) return false;
+ if (!cand.index && cand.bounds.area() > totalArea * 0.01) {
+ // index larger polygons (because they are slower to test via pointInRing()
+ // and they are more likely to be involved in repeated hit tests).
+ cand.index = new PolygonIndex([cand.ids], arcs);
+ }
+ return cand.index ?
+ cand.index.pointInPolygon(p[0], p[1]) :
+ geom.testPointInRing(p[0], p[1], cand.ids, arcs);
+ }
+
+ //
function testPointInRings(p, cands) {
var isOn = false,
isIn = false;
cands.forEach(function(cand) {
- var inRing = cand.index ?
- cand.index.pointInPolygon(p[0], p[1]) :
- pathContainsPoint(cand.ids, cand.bounds, p);
+ var inRing = testPointInRing(p, cand);
if (inRing == -1) {
isOn = true;
} else if (inRing == 1) {
@@ -120,43 +156,42 @@ function PathIndex(shapes, arcs) {
return isOn || isIn;
}
- function findPointHitShapes(p) {
- var rings = findPointHitRings(p),
- shapes = [],
- shape, bbox;
- if (rings.length > 0) {
- rings.sort(function(a, b) {return a.id - b.id;});
- for (var i=0; i 0) {
+ items.sort(function(a, b) {return a.id - b.id;});
+ for (var i=0; i 0 ? buffer : 0;
+ var x = p[0], y = p[1];
+ return boundsQuery(p[0] - b, p[1] - b, p[0] + b, p[1] + b);
}
- function getTestPoint(arcId) {
- // test point halfway along first segment because ring might still be
- // enclosed if a segment endpoint touches an indexed ring.
- var p0 = arcs.getVertex(arcId, 0),
+ // Find a point on a ring to use for point-in-polygon testing
+ function getTestPoint(ring) {
+ // Use the point halfway along first segment rather than an endpoint
+ // (because ring might still be enclosed if a segment endpoint touches an indexed ring.)
+ // The returned point should work for point-in-polygon testing if two rings do not
+ // share any common segments (which should be true for topological datasets)
+ // TODO: consider alternative of finding an internal point of @ring (slower but
+ // potentially more reliable).
+ var arcId = ring[0],
+ p0 = arcs.getVertex(arcId, 0),
p1 = arcs.getVertex(arcId, 1);
return [(p0.x + p1.x) / 2, (p0.y + p1.y) / 2];
}
- function pathContainsPoint(pathIds, pathBounds, p) {
- if (pathBounds.containsPoint(p[0], p[1]) === false) return 0;
- // A contains B iff some point on B is inside A
- return geom.testPointInRing(p[0], p[1], pathIds, arcs);
- }
-
function xorArrays(a, b) {
var xor = [];
a.forEach(function(el) {
diff --git a/src/paths/mapshaper-pathfinder.js b/src/paths/mapshaper-pathfinder.js
index 9a1ff9ccf..d11776d71 100644
--- a/src/paths/mapshaper-pathfinder.js
+++ b/src/paths/mapshaper-pathfinder.js
@@ -56,7 +56,7 @@ internal.openArcRoutes = function(arcIds, arcs, flags, fwd, rev, dissolve, orBit
// error condition: lollipop arcs can cause problems; ignore these
if (arcs.arcIsLollipop(id)) {
- trace('lollipop');
+ debug('lollipop');
newFlag = 0; // unset (i.e. make invisible)
} else {
if (openFwd) {
@@ -143,7 +143,7 @@ internal.getPathFinder = function(nodes, useRoute, routeIsUsable) {
if (candId == ~nextId) {
// TODO: handle or prevent this error condition
- message("Pathfinder warning: dead-end path");
+ debug("Pathfinder warning: dead-end path");
return null;
}
} while (candId != startId);
@@ -169,7 +169,7 @@ internal.getRingIntersector = function(nodes, type, flags) {
if (rings.length > 0) {
output = [];
internal.openArcRoutes(rings, arcs, flags, openFwd, openRev, dissolve);
- internal.forEachPath(rings, function(ids) {
+ internal.forEachShapePart(rings, function(ids) {
var path;
for (var i=0, n=ids.length; i 0 ? opts.buckets : segCount / 100;
+ // default is this many segs per bucket (average)
+ // var buckets = opts && opts.buckets > 0 ? opts.buckets : segCount / 200;
+ // using more segs/bucket for more complex shapes, based on trial and error
+ var buckets = Math.pow(segCount, 0.75) / 10;
return Math.ceil(buckets);
}
@@ -72,12 +74,12 @@ function PolygonIndex(shape, arcs, opts) {
a, b, i, j, xmin, xmax;
// get array of segments as [s0p0, s0p1, s1p0, s1p1, ...], sorted by xmin coordinate
- internal.forEachPathSegment(shape, arcs, function() {
+ internal.forEachSegmentInShape(shape, arcs, function() {
segCount++;
});
segments = new Uint32Array(segCount * 2);
i = 0;
- internal.forEachPathSegment(shape, arcs, function(a, b, xx, yy) {
+ internal.forEachSegmentInShape(shape, arcs, function(a, b, xx, yy) {
segments[i++] = a;
segments[i++] = b;
});
diff --git a/src/paths/mapshaper-polygon-repair.js b/src/paths/mapshaper-polygon-repair.js
index 39675fde6..971d3b8d4 100644
--- a/src/paths/mapshaper-polygon-repair.js
+++ b/src/paths/mapshaper-polygon-repair.js
@@ -1,6 +1,6 @@
/* @require mapshaper-polygon-holes */
-// clean polygon or polyline shapes, in-place
+// Clean polygon or polyline shapes (in-place)
//
internal.cleanShapes = function(shapes, arcs, type) {
for (var i=0, n=shapes.length; i maxLen) {
+ maxLen = len;
+ maxPart = path;
+ }
+ });
+ return maxPart;
+};
diff --git a/src/paths/mapshaper-ring-nesting.js b/src/paths/mapshaper-ring-nesting.js
new file mode 100644
index 000000000..4d1242656
--- /dev/null
+++ b/src/paths/mapshaper-ring-nesting.js
@@ -0,0 +1,50 @@
+/* @requires mapshaper-shape-utils, mapshaper-path-index */
+
+// Delete rings that are nested directly inside an enclosing ring with the same winding direction
+// Does not remove unenclosed CCW rings (currently this causes problems when
+// rounding coordinates for SVG and TopoJSON output)
+// Assumes ring boundaries do not overlap (should be true after e.g. dissolving)
+//
+internal.fixNestingErrors = function(rings, arcs) {
+ if (rings.length <= 1) return rings;
+ var ringData = internal.getPathMetadata(rings, arcs, 'polygon');
+ // convert rings to shapes for PathIndex
+ var shapes = rings.map(function(ids) {return [ids];});
+ var index = new PathIndex(shapes, arcs);
+ return rings.filter(ringIsValid);
+
+ function ringIsValid(ids, i) {
+ var containerId = index.findSmallestEnclosingPolygon(ids);
+ var ringIsCW, containerIsCW;
+ var valid = true;
+ if (containerId > -1) {
+ ringIsCW = ringData[i].area > 0;
+ containerIsCW = ringData[containerId].area > 0;
+ if (containerIsCW == ringIsCW) {
+ // reject rings with same chirality as their containing ring
+ valid = false;
+ }
+ }
+ return valid;
+ }
+};
+
+// Convert CCW rings that are not contained into CW rings
+internal.fixNestingErrors2 = function(rings, arcs) {
+ var ringData = internal.getPathMetadata(rings, arcs, 'polygon');
+ // convert rings to shapes for PathIndex
+ var shapes = rings.map(function(ids) {return [ids];});
+ var index = new PathIndex(shapes, arcs);
+ rings.forEach(fixRing);
+ // TODO: consider other kinds of nesting errors
+ function fixRing(ids, i) {
+ var ringIsCW = ringData[i].area > 0;
+ var containerId;
+ if (!ringIsCW) {
+ containerId = index.findSmallestEnclosingPolygon(ids);
+ if (containerId == -1) {
+ internal.reversePath(ids);
+ }
+ }
+ }
+};
diff --git a/src/paths/mapshaper-segment-intersection-info.js b/src/paths/mapshaper-segment-intersection-info.js
new file mode 100644
index 000000000..40cbc0def
--- /dev/null
+++ b/src/paths/mapshaper-segment-intersection-info.js
@@ -0,0 +1,28 @@
+// TODO: improve
+
+// internal.getIntersectionDebugData = function(o, arcs) {
+// var data = arcs.getVertexData();
+// var a = o.a;
+// var b = o.b;
+// o = utils.extend({}, o);
+// o.ax1 = data.xx[a[0]];
+// o.ay1 = data.yy[a[0]];
+// o.ax2 = data.xx[a[1]];
+// o.ay2 = data.yy[a[1]];
+// o.bx1 = data.xx[b[0]];
+// o.by1 = data.yy[b[0]];
+// o.bx2 = data.xx[b[1]];
+// o.by2 = data.yy[b[1]];
+// return o;
+// };
+
+// internal.debugSegmentIntersection = function(p, ax, ay, bx, by, cx, cy, dx, dy) {
+// debug('[debugSegmentIntersection()]');
+// debug(' s1\n dx:', Math.abs(ax - bx), '\n dy:', Math.abs(ay - by));
+// debug(' s2\n dx:', Math.abs(cx - dx), '\n dy:', Math.abs(cy - dy));
+// debug(' s1 xx:', ax, bx);
+// debug(' s2 xx:', cx, dx);
+// debug(' s1 yy:', ay, by);
+// debug(' s2 yy:', cy, dy);
+// debug(' angle:', geom.signedAngle(ax, ay, bx, by, dx - cx + bx, dy - cy + by));
+// };
\ No newline at end of file
diff --git a/src/paths/mapshaper-segment-intersection.js b/src/paths/mapshaper-segment-intersection.js
index b07ca4637..00b2b055d 100644
--- a/src/paths/mapshaper-segment-intersection.js
+++ b/src/paths/mapshaper-segment-intersection.js
@@ -28,16 +28,17 @@ internal.findSegmentIntersections = (function() {
return new Uint32Array(buf, 0, count);
}
- return function(arcs) {
- var bounds = arcs.getBounds(),
+ return function(arcs, arg2) {
+ var opts = arg2 || {},
+ bounds = arcs.getBounds(),
// TODO: handle spherical bounds
spherical = !arcs.isPlanar() &&
containsBounds(internal.getWorldBounds(), bounds.toArray()),
ymin = bounds.ymin,
yrange = bounds.ymax - ymin,
- stripeCount = internal.calcSegmentIntersectionStripeCount(arcs),
+ stripeCount = opts.stripes || internal.calcSegmentIntersectionStripeCount(arcs),
stripeSizes = new Uint32Array(stripeCount),
- stripeId = stripeCount > 1 ? multiStripeId : singleStripeId,
+ stripeId = stripeCount > 1 && yrange > 0 ? multiStripeId : singleStripeId,
i, j;
function multiStripeId(y) {
@@ -45,7 +46,6 @@ internal.findSegmentIntersections = (function() {
}
function singleStripeId(y) {return 0;}
-
// Count segments in each stripe
arcs.forEachSegment(function(id1, id2, xx, yy) {
var s1 = stripeId(yy[id1]),
@@ -94,6 +94,7 @@ internal.findSegmentIntersections = (function() {
intersections.push(arr[j]);
}
}
+
return internal.dedupIntersections(intersections);
};
})();
@@ -122,9 +123,25 @@ internal.getIntersectionKey = function(o) {
return o.a.join(',') + ';' + o.b.join(',');
};
+// Fast method
+// TODO: measure performance using a range of input data
+internal.calcSegmentIntersectionStripeCount2 = function(arcs) {
+ var segs = arcs.getFilteredPointCount() - arcs.size();
+ var stripes = Math.pow(segs, 0.4) * 2;
+ return Math.ceil(stripes) || 1;
+};
+
+// Alternate fast method
internal.calcSegmentIntersectionStripeCount = function(arcs) {
+ var segs = arcs.getFilteredPointCount() - arcs.size();
+ var stripes = Math.ceil(Math.pow(segs * 10, 0.6) / 40);
+ return stripes > 0 ? stripes : 1;
+};
+
+// Old method calculates average segment length -- slow
+internal.calcSegmentIntersectionStripeCount_old = function(arcs) {
var yrange = arcs.getBounds().height(),
- segLen = internal.getAvgSegment2(arcs)[1],
+ segLen = internal.getAvgSegment2(arcs)[1], // slow
count = 1;
if (segLen > 0 && yrange > 0) {
count = Math.ceil(yrange / segLen / 20);
@@ -190,7 +207,7 @@ internal.intersectSegments = function(ids, xx, yy) {
}
// test two candidate segments for intersection
- hit = segmentIntersection(s1p1x, s1p1y, s1p2x, s1p2y,
+ hit = geom.segmentIntersection(s1p1x, s1p1y, s1p2x, s1p2y,
s2p1x, s2p1y, s2p2x, s2p2y);
if (hit) {
seg1 = [s1p1, s1p2];
diff --git a/src/paths/mapshaper-shape-iter.js b/src/paths/mapshaper-shape-iter.js
index 59b26831b..3de1a452f 100644
--- a/src/paths/mapshaper-shape-iter.js
+++ b/src/paths/mapshaper-shape-iter.js
@@ -1,13 +1,38 @@
-// Constructor takes arrays of coords: xx, yy, zz (optional)
+// Coordinate iterators
+//
+// Interface:
+// properties: x, y
+// method: hasNext()
//
-// Iterate over the points of an arc
-// properties: x, y
-// method: hasNext()
-// usage:
+// Usage:
// while (iter.hasNext()) {
// iter.x, iter.y; // do something w/ x & y
// }
+
+
+// Iterate over an array of [x, y] points
+//
+function PointIter(points) {
+ var n = points.length,
+ i = 0,
+ iter = {
+ x: 0,
+ y: 0,
+ hasNext: hasNext
+ };
+ function hasNext() {
+ if (i >= n) return false;
+ iter.x = points[i][0];
+ iter.y = points[i][1];
+ i++;
+ return true;
+ }
+ return iter;
+}
+
+
+// Constructor takes arrays of coords: xx, yy, zz (optional)
//
function ArcIter(xx, yy) {
this._i = 0;
@@ -86,7 +111,6 @@ function FilteredArcIter(xx, yy, zz) {
}
// Iterate along a path made up of one or more arcs.
-// Similar interface to ArcIter()
//
function ShapeIter(arcs) {
this._arcs = arcs;
diff --git a/src/paths/mapshaper-snapping.js b/src/paths/mapshaper-snapping.js
index 42e59df26..eec4a99c9 100644
--- a/src/paths/mapshaper-snapping.js
+++ b/src/paths/mapshaper-snapping.js
@@ -21,7 +21,6 @@ internal.snapCoords = function(arcs, threshold) {
snapDist = threshold;
message(utils.format("Applying snapping threshold of %s -- %.6f times avg. segment length", threshold, threshold / avgDist));
}
-
var snapCount = internal.snapCoordsByInterval(arcs, snapDist);
if (snapCount > 0) arcs.dedupCoords();
message(utils.format("Snapped %s point%s", snapCount, utils.pluralSuffix(snapCount)));
diff --git a/src/geom/mapshaper-polygon-centroid.js b/src/points/mapshaper-anchor-points.js
similarity index 80%
rename from src/geom/mapshaper-polygon-centroid.js
rename to src/points/mapshaper-anchor-points.js
index 2fe58e7e1..025e71035 100644
--- a/src/geom/mapshaper-polygon-centroid.js
+++ b/src/points/mapshaper-anchor-points.js
@@ -1,38 +1,5 @@
-/* @requires mapshaper-shape-geom, mapshaper-simplify-fast */
+/* @requires mapshaper-shape-geom mapshaper-simplify-fast */
-// Get the centroid of the largest ring of a polygon
-// TODO: Include holes in the calculation
-// TODO: Add option to find centroid of all rings, not just the largest
-geom.getShapeCentroid = function(shp, arcs) {
- var maxPath = geom.getMaxPath(shp, arcs);
- return maxPath ? geom.getPathCentroid(maxPath, arcs) : null;
-};
-
-geom.getPathCentroid = function(ids, arcs) {
- var iter = arcs.getShapeIter(ids),
- sum = 0,
- sumX = 0,
- sumY = 0,
- ax, ay, tmp, area;
- if (!iter.hasNext()) return null;
- ax = iter.x;
- ay = iter.y;
- while (iter.hasNext()) {
- tmp = ax * iter.y - ay * iter.x;
- sum += tmp;
- sumX += tmp * (iter.x + ax);
- sumY += tmp * (iter.y + ay);
- ax = iter.x;
- ay = iter.y;
- }
- area = sum / 2;
- if (area === 0) {
- return geom.getAvgPathXY(ids, arcs);
- } else return {
- x: sumX / (6 * area),
- y: sumY / (6 * area)
- };
-};
// Find a point inside a polygon and located away from the polygon edge
// Method:
@@ -47,23 +14,28 @@ geom.getPathCentroid = function(ids, arcs) {
//
// (distance is weighted to slightly favor points near centroid)
//
-geom.findInteriorPoint = function(shp, arcs) {
+internal.findAnchorPoint = function(shp, arcs) {
var maxPath = shp && geom.getMaxPath(shp, arcs),
pathBounds = maxPath && arcs.getSimpleShapeBounds(maxPath),
thresh, simple;
if (!pathBounds || !pathBounds.hasBounds() || pathBounds.area() === 0) {
return null;
}
+ // Optimization: quickly simplify using a relatively small distance threshold.
+ // (testing multiple candidate points can be very slow for large and detailed
+ // polgons; simplification alleviates this)
+ // Caveat: In rare cases this could cause poor point placement, e.g. if
+ // simplification causes small holes to be removed.
thresh = Math.sqrt(pathBounds.area()) * 0.01;
simple = internal.simplifyPolygonFast(shp, arcs, thresh);
if (!simple.shape) {
return null; // collapsed shape
}
- return geom.findInteriorPoint2(simple.shape, simple.arcs);
+ return internal.findAnchorPoint2(simple.shape, simple.arcs);
};
// Assumes: shp is a polygon with at least one space-enclosing ring
-geom.findInteriorPoint2 = function(shp, arcs) {
+internal.findAnchorPoint2 = function(shp, arcs) {
var maxPath = geom.getMaxPath(shp, arcs);
var pathBounds = arcs.getSimpleShapeBounds(maxPath);
var centroid = geom.getPathCentroid(maxPath, arcs);
@@ -88,13 +60,13 @@ geom.findInteriorPoint2 = function(shp, arcs) {
hstep = hrange / htics;
// Find a best-fit point
- p = internal.probeForBestInteriorPoint(shp, arcs, lbound, rbound, htics, weight);
+ p = internal.probeForBestAnchorPoint(shp, arcs, lbound, rbound, htics, weight);
if (!p) {
verbose("[points inner] failed, falling back to centroid");
p = centroid;
} else {
// Look for even better fit close to best-fit point
- p2 = internal.probeForBestInteriorPoint(shp, arcs, p.x - hstep / 2,
+ p2 = internal.probeForBestAnchorPoint(shp, arcs, p.x - hstep / 2,
p.x + hstep / 2, 2, weight);
if (p2.distance > p.distance) {
p = p2;
@@ -113,7 +85,7 @@ internal.getPointWeightingFunction = function(centroid, pathBounds) {
};
};
-internal.findInteriorPointCandidates = function(shp, arcs, xx) {
+internal.findAnchorPointCandidates = function(shp, arcs, xx) {
var ymin = arcs.getBounds().ymin - 1;
return xx.reduce(function(memo, x) {
var cands = internal.findHitCandidates(x, ymin, shp, arcs);
@@ -121,11 +93,11 @@ internal.findInteriorPointCandidates = function(shp, arcs, xx) {
}, []);
};
-internal.probeForBestInteriorPoint = function(shp, arcs, lbound, rbound, htics, weight) {
+internal.probeForBestAnchorPoint = function(shp, arcs, lbound, rbound, htics, weight) {
var tics = internal.getInnerTics(lbound, rbound, htics);
var interval = (rbound - lbound) / htics;
// Get candidate points, distributed along x-axis
- var candidates = internal.findInteriorPointCandidates(shp, arcs, tics);
+ var candidates = internal.findAnchorPointCandidates(shp, arcs, tics);
var bestP, adjustedP, candP;
// Sort candidates so points at the center of longer segments are tried first
@@ -224,7 +196,7 @@ internal.findRayShapeIntersections = function(x, y, shp, arcs) {
// Return array of y-intersections between vertical ray and a polygon ring
internal.findRayRingIntersections = function(x, y, path, arcs) {
var yints = [];
- internal.forEachPathSegment(path, arcs, function(a, b, xx, yy) {
+ internal.forEachSegmentInPath(path, arcs, function(a, b, xx, yy) {
var result = geom.getRayIntersection(x, y, xx[a], yy[a], xx[b], yy[b]);
if (result > -Infinity) {
yints.push(result);
diff --git a/src/points/mapshaper-grids.js b/src/points/mapshaper-grids.js
new file mode 100644
index 000000000..f862bff64
--- /dev/null
+++ b/src/points/mapshaper-grids.js
@@ -0,0 +1,30 @@
+/* @requires mapshaper-common */
+
+
+// Returns a grid of [x,y] points so that point(c,r) == arr[r][c]
+// @f Function for creating a [x,y] point at a given (col, row) position
+//
+internal.generateGrid = function(cols, rows, f) {
+ var grid = [], gridRow, r, c;
+ for (r=0; r 0 === false) return; // skip holes
+ groups.push(utils.pluck(data.slice(i), 'ids'));
+ });
+ return groups;
+};
+
+
+// assume: shp[0] is outer ring
+internal.findInnerPoint = function(shp, arcs) {
+};
diff --git a/src/points/mapshaper-point-index.js b/src/points/mapshaper-point-index.js
new file mode 100644
index 000000000..9eef3a4d3
--- /dev/null
+++ b/src/points/mapshaper-point-index.js
@@ -0,0 +1,25 @@
+/* @requires mapshaper-point-utils, mapshaper-geom */
+
+// TODO: use an actual index instead of linear search
+function PointIndex(shapes, opts) {
+ var buf = utils.isNonNegNumber(opts.buffer) ? opts.buffer : 1e-3;
+ var minDistSq, minId, target;
+ this.findNearestPointFeature = function(shape) {
+ minDistSq = Infinity;
+ minId = -1;
+ target = shape || [];
+ internal.forEachPoint(shapes, testPoint);
+ return minId;
+ };
+
+ function testPoint(p, id) {
+ var distSq;
+ for (var i=0; i 0 || c != 32) { // ignore leading spaces (e.g. DBF numbers)
buf[count++] = c;
}
@@ -64,44 +68,38 @@ Dbf.readStringBytes = function(bin, size, buf) {
return count;
};
-Dbf.getAsciiStringReader = function() {
- var buf = new Uint8Array(256); // new Buffer(256);
- return function readAsciiString(bin, size) {
- var str = '',
- n = Dbf.readStringBytes(bin, size, buf);
- for (var i=0; i 127) {
+ return internal.bufferToString(buf, encoding, 0, n);
+ }
+ str += String.fromCharCode(c);
}
return str;
};
};
-Dbf.getStringReader = function(encoding) {
- if (!encoding || encoding === 'ascii') {
- return Dbf.getAsciiStringReader();
- // return Dbf.readAsciiString;
- } else {
- return Dbf.getEncodedStringReader(encoding);
- }
-};
-
Dbf.bufferContainsHighBit = function(buf, n) {
for (var i=0; i= 128) return true;
@@ -110,7 +108,7 @@ Dbf.bufferContainsHighBit = function(buf, n) {
};
Dbf.getNumberReader = function() {
- var read = Dbf.getAsciiStringReader();
+ var read = Dbf.getStringReader('ascii');
return function readNumber(bin, size) {
var str = read(bin, size);
var val;
@@ -212,7 +210,7 @@ function DbfReader(src, encodingArg) {
error("Record length mismatch; header:", header.recordSize, "detected:", colOffs);
}
if (bin.peek() != 0x0D) {
- message('[dbf] Found a non-standard header terminator (' + bin.peek() + '). DBF file may be corrupted.');
+ message('Found a non-standard DBF header terminator (' + bin.peek() + '). DBF file may be corrupted.');
}
// Uniqify header names
@@ -287,7 +285,7 @@ function DbfReader(src, encodingArg) {
field = fields[c];
fieldOffs = offs + field.columnOffset;
if (fieldOffs + field.size > eofOffs) {
- stop('[dbf] Invalid DBF file: encountered end-of-file while reading data');
+ stop('Invalid DBF file: encountered end-of-file while reading data');
}
bin.position(fieldOffs);
values[c] = readers[c](bin, field.size);
@@ -311,7 +309,7 @@ function DbfReader(src, encodingArg) {
} else if (type == 'C') {
r = Dbf.getStringReader(getEncoding());
} else {
- message("[dbf] Field \"" + field.name + "\" has an unsupported type (" + field.type + ") -- converting to null values");
+ message("Field \"" + field.name + "\" has an unsupported type (" + field.type + ") -- converting to null values");
r = function() {return null;};
}
return r;
@@ -346,14 +344,11 @@ function DbfReader(src, encodingArg) {
// Show a sample of decoded text if non-ascii-range text has been found
if (encoding && samples.length > 0) {
- msg = "Detected DBF text encoding: " + encoding;
- if (encoding in Dbf.encodingNames) {
- msg += " (" + Dbf.encodingNames[encoding] + ")";
- }
- message(msg);
msg = internal.decodeSamples(encoding, samples);
msg = internal.formatStringsAsGrid(msg.split('\n'));
- message("Sample text containing non-ascii characters:" + (msg.length > 60 ? '\n' : '') + msg);
+ msg = "\nSample text containing non-ascii characters:" + (msg.length > 60 ? '\n' : '') + msg;
+ msg = "Detected DBF text encoding: " + encoding + (encoding in Dbf.encodingNames ? " (" + Dbf.encodingNames[encoding] + ")" : "") + msg;
+ message(msg);
}
return encoding;
}
@@ -365,7 +360,7 @@ function DbfReader(src, encodingArg) {
var stringFields = header.fields.filter(function(f) {
return f.type == 'C';
});
- var buf = new Buffer(256);
+ var buf = utils.createBuffer(256);
var index = {};
var f, chars, sample, hash;
for (var r=0, rows=header.recordCount; r 0 && Dbf.bufferContainsHighBit(buf, chars)) {
- sample = new Buffer(buf.slice(0, chars)); //
+ sample = utils.createBuffer(buf.slice(0, chars)); //
hash = sample.toString('hex');
if (hash in index === false) { // avoid duplicate samples
index[hash] = true;
diff --git a/src/shapefile/dbf-writer.js b/src/shapefile/dbf-writer.js
index 4b5282d6c..97f2bf781 100644
--- a/src/shapefile/dbf-writer.js
+++ b/src/shapefile/dbf-writer.js
@@ -1,14 +1,46 @@
-/* @requires dbf-reader */
+/* @requires dbf-reader, mapshaper-encodings */
Dbf.MAX_STRING_LEN = 254;
-Dbf.exportRecords = function(arr, encoding) {
- encoding = encoding || 'ascii';
- var fields = Dbf.getFieldNames(arr);
- var uniqFields = internal.getUniqFieldNames(fields, 10);
- var rows = arr.length;
- var fieldData = fields.map(function(name) {
- return Dbf.getFieldInfo(arr, name, encoding);
+function BufferPool() {
+ var n = 5000,
+ pool, i;
+ newPool();
+
+ function newPool() {
+ pool = new Uint8Array(n);
+ i = 0;
+ }
+
+ return {
+ reserve: function(bytes) {
+ if (i + bytes > n) newPool();
+ i += bytes;
+ return pool.subarray(i - bytes, i);
+ },
+ putBack: function(bytes) {
+ i -= bytes;
+ }
+ };
+}
+
+Dbf.bufferPool = new BufferPool();
+
+Dbf.exportRecords = function(records, encoding, fieldOrder) {
+ var rows = records.length;
+ var fields = internal.findFieldNames(records, fieldOrder);
+ var dbfFields = Dbf.convertFieldNames(fields);
+ var fieldData = fields.map(function(name, i) {
+ var info = Dbf.getFieldInfo(records, name, encoding || 'utf8');
+ var name2 = dbfFields[i];
+ info.name = name2;
+ if (name != name2) {
+ message('Changed field name from "' + name + '" to "' + name2 + '"');
+ }
+ if (info.warning) {
+ message('[' + name + '] ' + info.warning);
+ }
+ return info;
});
var headerBytes = Dbf.getHeaderSize(fieldData.length),
@@ -31,10 +63,10 @@ Dbf.exportRecords = function(arr, encoding) {
bin.writeUint8(0); // language flag; TODO: improve this
bin.skipBytes(2);
+
// field subrecords
- fieldData.reduce(function(recordOffset, obj, i) {
- var fieldName = uniqFields[i];
- bin.writeCString(fieldName, 11);
+ fieldData.reduce(function(recordOffset, obj) {
+ bin.writeCString(obj.name, 11);
bin.writeUint8(obj.type.charCodeAt(0));
bin.writeUint32(recordOffset);
bin.writeUint8(obj.size);
@@ -48,7 +80,7 @@ Dbf.exportRecords = function(arr, encoding) {
error("Dbf#exportRecords() header size mismatch; expected:", headerBytes, "written:", bin.position());
}
- arr.forEach(function(rec, i) {
+ records.forEach(function(rec, i) {
var start = bin.position();
bin.writeUint8(0x20); // delete flag; 0x20 valid 0x2a deleted
for (var j=0, n=fieldData.length; j Dbf.MAX_STRING_LEN) {
+ if (encoding == 'ascii') {
+ buf = buf.subarray(0, Dbf.MAX_STRING_LEN);
+ } else {
+ buf = Dbf.truncateEncodedString(buf, encoding, Dbf.MAX_STRING_LEN);
+ }
+ truncated++;
+ }
+ size = Math.max(size, buf.length);
return buf;
});
info.size = size;
info.write = function(i, bin) {
- var buf = values[i],
- bytes = Math.min(size, buf.byteLength),
- idx = bin.position();
- bin.writeBuffer(buf, bytes, 0);
- bin.position(idx + size);
+ var buf = buffers[i],
+ n = Math.min(size, buf.length),
+ dest = bin._bytes,
+ pos = bin.position(),
+ j;
+ for (j=0; j 0) {
+ info.warning = 'Truncated ' + truncated + ' string' + (truncated == 1 ? '' : 's') + ' to fit the 254-byte limit';
+ }
+};
+
+Dbf.convertFieldNames = function(names) {
+ return internal.getUniqFieldNames(names.map(Dbf.cleanFieldName), 10);
+};
+
+// Replace non-alphanumeric characters with _ and merge adjacent _
+// See: https://desktop.arcgis.com/en/arcmap/latest/manage-data/tables/fundamentals-of-adding-and-deleting-fields.htm#GUID-8E190093-8F8F-4132-AF4F-B0C9220F76B3
+// TODO: decide whether or not to avoid initial numerals
+Dbf.cleanFieldName = function(name) {
+ return name.replace(/[^A-Za-z0-9]+/g, '_');
};
Dbf.getFieldInfo = function(arr, name, encoding) {
var type = this.discoverFieldType(arr, name),
info = {
- name: name,
type: type,
decimals: 0
};
@@ -187,6 +232,9 @@ Dbf.getFieldInfo = function(arr, name, encoding) {
// again as nulls.
info.size = 0;
info.type = 'N';
+ if (type) {
+ info.warning = 'Unable to export ' + type + '-type data, writing null values';
+ }
info.write = function() {};
}
return info;
@@ -200,6 +248,7 @@ Dbf.discoverFieldType = function(arr, name) {
if (utils.isNumber(val)) return "N";
if (utils.isBoolean(val)) return "L";
if (val instanceof Date) return "D";
+ if (val) return (typeof val);
}
return null;
};
@@ -253,38 +302,37 @@ Dbf.getNumericFieldInfo = function(arr, name) {
};
};
-// Return function to convert a JS str to an ArrayBuffer containing encoded str.
-Dbf.getStringWriter = function(encoding) {
- if (encoding === 'ascii') {
- return Dbf.getStringWriterAscii();
- } else {
- return Dbf.getStringWriterEncoded(encoding);
- }
-};
-
-// TODO: handle non-ascii chars. Option: switch to
-// utf8 encoding if non-ascii chars are found.
-Dbf.getStringWriterAscii = function() {
- return function(val) {
- var str = String(val),
- n = Math.min(str.length, Dbf.MAX_STRING_LEN),
- dest = new ArrayBuffer(n),
- view = new Uint8ClampedArray(dest);
- for (var i=0; i 127) {
+ if (strict) {
+ view = null;
+ i = 0; // return all bytes to pool
+ break;
+ }
+ c = '?'.charCodeAt(0);
}
- return dest;
- };
+ view[i] = c;
+ }
+ Dbf.bufferPool.putBack(n-i);
+ return view ? view.subarray(0, i) : null;
};
Dbf.getStringWriterEncoded = function(encoding) {
- var iconv = require('iconv-lite');
return function(val) {
- var buf = iconv.encode(val, encoding);
- if (buf.length >= Dbf.MAX_STRING_LEN) {
- buf = Dbf.truncateEncodedString(buf, encoding, Dbf.MAX_STRING_LEN);
+ // optimization -- large majority of strings in real-world datasets are
+ // ascii. Try (faster) ascii encoding first, fall back to text encoder.
+ var buf = Dbf.encodeValueAsAscii(val, true);
+ if (buf === null) {
+ buf = internal.encodeString(String(val), encoding);
}
- return BinArray.toArrayBuffer(buf);
+ return buf;
};
};
diff --git a/src/shapefile/shp-common.js b/src/shapefile/shp-common.js
index 53962e02a..231bfc29f 100644
--- a/src/shapefile/shp-common.js
+++ b/src/shapefile/shp-common.js
@@ -18,11 +18,3 @@ internal.translateShapefileType = function(shpType) {
internal.isSupportedShapefileType = function(t) {
return utils.contains([0,1,3,5,8,11,13,15,18,21,23,25,28], t);
};
-
-internal.getShapefileType = function(type) {
- return {
- polygon: ShpType.POLYGON,
- polyline: ShpType.POLYLINE,
- point: ShpType.MULTIPOINT // TODO: use POINT when possible
- }[type] || ShpType.NULL;
-};
diff --git a/src/shapefile/shp-export.js b/src/shapefile/shp-export.js
index ed12e3a29..1b3845bbf 100644
--- a/src/shapefile/shp-export.js
+++ b/src/shapefile/shp-export.js
@@ -1,6 +1,8 @@
/* @requires
shp-common
mapshaper-path-export
+mapshaper-projections
+mapshaper-shape-utils
*/
// Convert a dataset to Shapefile files
@@ -15,40 +17,66 @@ internal.exportShapefile = function(dataset, opts) {
};
internal.exportPrjFile = function(lyr, dataset) {
- var crs, inputPrj, outputPrj;
- if (dataset.info) {
- inputPrj = dataset.info.input_prj;
- crs = dataset.info.crs;
+ var info = dataset.info || {};
+ var prj = info.prj;
+ if (!prj) {
+ try {
+ prj = internal.crsToPrj(internal.getDatasetCRS(dataset));
+ } catch(e) {}
}
- if (crs && inputPrj && internal.crsAreEqual(crs, internal.parsePrj(inputPrj))) {
- outputPrj = inputPrj;
- } else if (!crs && inputPrj) { // dataset has not been reprojected
- outputPrj = inputPrj;
+ if (!prj) {
+ message("Unable to generate .prj file for", lyr.name + '.shp');
}
- // TODO: generate .prj if projection is known
-
- return outputPrj ? {
- content: outputPrj,
+ return prj ? {
+ content: prj,
filename: lyr.name + '.prj'
} : null;
};
+internal.getShapefileExportType = function(lyr) {
+ var type = lyr.geometry_type;
+ var shpType;
+ if (type == 'point') {
+ shpType = internal.findMaxPartCount(lyr.shapes || []) <= 1 ? ShpType.POINT : ShpType.MULTIPOINT;
+ } else if (type == 'polygon') {
+ shpType = ShpType.POLYGON;
+ } else if (type == 'polyline') {
+ shpType = ShpType.POLYLINE;
+ } else {
+ shpType = ShpType.NULL;
+ }
+ return shpType;
+};
+
internal.exportShpAndShxFiles = function(layer, dataset, opts) {
- var geomType = layer.geometry_type;
- var shpType = internal.getShapefileType(geomType);
- var fileBytes = 100;
- var bounds = new Bounds();
var shapes = layer.shapes || utils.initializeArray(new Array(internal.getFeatureCount(layer)), null);
+ var bounds = new Bounds();
+ var shpType = internal.getShapefileExportType(layer);
+ var fileBytes = 100;
+ var shxBytes = 100 + shapes.length * 8;
+ var shxBin = new BinArray(shxBytes).bigEndian().position(100); // jump to record section
+ var shpBin;
+
+ // TODO: consider writing records to an expanding buffer instead of generating
+ // individual buffers for each record (for large point datasets,
+ // creating millions of buffers impacts performance significantly)
var shapeBuffers = shapes.map(function(shape, i) {
- var pathData = internal.exportPathData(shape, dataset.arcs, geomType);
+ var pathData = internal.exportPathData(shape, dataset.arcs, layer.geometry_type);
var rec = internal.exportShpRecord(pathData, i+1, shpType);
- fileBytes += rec.buffer.byteLength;
+ var recBytes = rec.buffer.byteLength;
+
+ // add shx record
+ shxBin.writeInt32(fileBytes / 2); // record offset in 16-bit words
+ // alternative to below: shxBin.writeBuffer(rec.buffer, 4, 4)
+ shxBin.writeInt32(recBytes / 2 - 4); // record content length in 16-bit words
+
+ fileBytes += recBytes;
if (rec.bounds) bounds.mergeBounds(rec.bounds);
return rec.buffer;
});
// write .shp header section
- var shpBin = new BinArray(fileBytes, false)
+ shpBin = new BinArray(fileBytes, false)
.writeInt32(9994)
.skipBytes(5 * 4)
.writeInt32(fileBytes / 2)
@@ -65,27 +93,19 @@ internal.exportShpAndShxFiles = function(layer, dataset, opts) {
// no bounds -- assume no shapes or all null shapes -- using 0s as bbox
shpBin.skipBytes(4 * 8);
}
-
shpBin.skipBytes(4 * 8); // skip Z & M type bounding boxes;
- // write .shx header
- var shxBytes = 100 + shapeBuffers.length * 8;
- var shxBin = new BinArray(shxBytes, false)
- .writeBuffer(shpBin.buffer(), 100) // copy .shp header to .shx
- .position(24)
- .bigEndian()
- .writeInt32(shxBytes/2)
- .position(100);
-
- // write record sections of .shp and .shx
- shapeBuffers.forEach(function(buf, i) {
- var shpOff = shpBin.position() / 2,
- shpSize = (buf.byteLength - 8) / 2; // alternative: shxBin.writeBuffer(buf, 4, 4);
- shxBin.writeInt32(shpOff);
- shxBin.writeInt32(shpSize);
+ // write records section of .shp
+ shapeBuffers.forEach(function(buf) {
shpBin.writeBuffer(buf);
});
+ // write .shx header
+ shxBin.position(0)
+ .writeBuffer(shpBin.buffer(), 100) // copy .shp header to .shx
+ .position(24) // substitute shx file size for shp file size
+ .writeInt32(shxBytes / 2);
+
return [{
content: shpBin.buffer(),
filename: layer.name + ".shp"
@@ -100,16 +120,34 @@ internal.exportShpAndShxFiles = function(layer, dataset, opts) {
// TODO: remove collapsed rings, convert to null shape if necessary
//
internal.exportShpRecord = function(data, id, shpType) {
- var bounds = null,
+ var multiPartType = ShpType.isMultiPartType(shpType),
+ singlePointType = !multiPartType && !ShpType.isMultiPointType(shpType),
+ isNull = data.pointCount > 0 === false,
+ bounds = isNull ? null : data.bounds,
bin = null;
- if (data.pointCount > 0) {
- var multiPart = ShpType.isMultiPartType(shpType),
- partIndexIdx = 52,
- pointsIdx = multiPart ? partIndexIdx + 4 * data.pathCount : 48,
+
+ if (isNull) {
+ bin = new BinArray(12, false)
+ .writeInt32(id)
+ .writeInt32(2)
+ .littleEndian()
+ .writeInt32(0);
+
+ } else if (singlePointType) {
+ bin = new BinArray(28, false)
+ .writeInt32(id)
+ .writeInt32(10)
+ .littleEndian()
+ .writeInt32(shpType)
+ .writeFloat64(data.pathData[0].points[0][0])
+ .writeFloat64(data.pathData[0].points[0][1]);
+
+ } else {
+ var partIndexIdx = 52,
+ pointsIdx = multiPartType ? partIndexIdx + 4 * data.pathCount : 48,
recordBytes = pointsIdx + 16 * data.pointCount,
pointCount = 0;
- bounds = data.bounds;
bin = new BinArray(recordBytes, false)
.writeInt32(id)
.writeInt32((recordBytes - 8) / 2)
@@ -120,40 +158,26 @@ internal.exportShpRecord = function(data, id, shpType) {
.writeFloat64(bounds.xmax)
.writeFloat64(bounds.ymax);
- if (multiPart) {
+ if (multiPartType) {
bin.writeInt32(data.pathCount);
- } else {
- if (data.pathData.length > 1) {
- error("[exportShpRecord()] Tried to export multiple paths as type:", shpType);
- }
}
bin.writeInt32(data.pointCount);
-
data.pathData.forEach(function(path, i) {
- if (multiPart) {
+ if (multiPartType) {
bin.position(partIndexIdx + i * 4).writeInt32(pointCount);
}
bin.position(pointsIdx + pointCount * 16);
-
- var points = path.points;
- for (var j=0, len=points.length; j 0) {
- // Encountered in ne_10m_railroads.shp from natural earth v2.0.0
- message("[shp] Skipped " + skippedBytes + " bytes in .shp file -- possible data loss.");
+ // Encountered in files from natural earth v2.0.0:
+ // ne_10m_admin_0_boundary_lines_land.shp
+ // ne_110m_admin_0_scale_rank.shp
+ verbose("Skipped over " + skippedBytes + " non-data bytes in the .shp file.");
}
- file.close();
+ shpFile.close();
reset();
}
return shape;
};
+ function readNextShape() {
+ var expectedId = recordCount + 1; // Shapefile ids are 1-based
+ var shape, offset;
+ if (done()) return null;
+ if (shxBin) {
+ shxBin.position(100 + recordCount * 8);
+ offset = shxBin.readUint32() * 2;
+ if (offset > shpOffset) {
+ skippedBytes += offset - shpOffset;
+ }
+ } else {
+ offset = shpOffset;
+ }
+ shape = readShapeAtOffset(offset);
+ if (!shape) {
+ // Some in-the-wild .shp files contain junk bytes between records. This
+ // is a problem if the .shx index file is not present.
+ // Here, we try to scan past the junk to find the next record.
+ shape = huntForNextShape(offset, expectedId);
+ }
+ if (shape) {
+ if (shape.id < expectedId) {
+ message("Found a Shapefile record with the same id as a previous record (" + shape.id + ") -- skipping.");
+ return readNextShape();
+ } else if (shape.id > expectedId) {
+ stop("Shapefile contains an out-of-sequence record. Possible data corruption -- bailing.");
+ }
+ recordCount++;
+ }
+ return shape || null;
+ }
+
+ function done() {
+ if (shxFile && shxFile.size() <= 100 + recordCount * 8) return true;
+ if (shpOffset + 12 > shpSize) return true;
+ return false;
+ }
+
function reset() {
- recordOffs = 100;
+ shpOffset = 100;
skippedBytes = 0;
- i = 1; // Shapefile id of first record
+ recordCount = 0;
}
function parseHeader(bin) {
@@ -101,29 +133,29 @@ function ShpReader(src) {
error("Unsupported .shp type:", header.type);
}
- if (header.byteLength != file.size()) {
+ if (header.byteLength != shpFile.size()) {
error("File size of .shp doesn't match size in header");
}
return header;
}
- function readShapeAtOffset(recordOffs, i) {
+ function readShapeAtOffset(offset) {
var shape = null,
- recordSize, recordType, recordId, goodId, goodSize, goodType, bin;
+ recordSize, recordType, recordId, goodSize, goodType, bin;
- if (recordOffs + 12 <= fileSize) {
- bin = file.readBytes(12, recordOffs);
+ if (offset + 12 <= shpSize) {
+ bin = shpFile.readToBinArray(offset, 12);
recordId = bin.bigEndian().readUint32();
// record size is bytes in content section + 8 header bytes
recordSize = bin.readUint32() * 2 + 8;
recordType = bin.littleEndian().readUint32();
- goodId = recordId == i; // not checking id ...
- goodSize = recordOffs + recordSize <= fileSize && recordSize >= 12;
+ goodSize = offset + recordSize <= shpSize && recordSize >= 12;
goodType = recordType === 0 || recordType == header.type;
if (goodSize && goodType) {
- bin = file.readBytes(recordSize, recordOffs);
+ bin = shpFile.readToBinArray(offset, recordSize);
shape = new RecordClass(bin, recordSize);
+ shpOffset = offset + shape.byteLength; // advance read position
}
}
return shape;
@@ -132,21 +164,23 @@ function ShpReader(src) {
// TODO: add tests
// Try to scan past unreadable content to find next record
function huntForNextShape(start, id) {
- var offset = start,
+ var offset = start + 4,
shape = null,
- bin, recordId, recordType;
- while (offset + 12 <= fileSize) {
- bin = file.readBytes(12, offset);
+ bin, recordId, recordType, count;
+ while (offset + 12 <= shpSize) {
+ bin = shpFile.readToBinArray(offset, 12);
recordId = bin.bigEndian().readUint32();
recordType = bin.littleEndian().skipBytes(4).readUint32();
if (recordId == id && (recordType == header.type || recordType === 0)) {
// we have a likely position, but may still be unparsable
- shape = readShapeAtOffset(offset, id);
+ shape = readShapeAtOffset(offset);
break;
}
offset += 4; // try next integer position
}
- skippedBytes += shape ? offset - start : fileSize - start;
+ count = shape ? offset - start : shpSize - start;
+ // debug('Skipped', count, 'bytes', shape ? 'before record ' + id : 'at the end of the file');
+ skippedBytes += count;
return shape;
}
}
@@ -170,63 +204,3 @@ ShpReader.prototype.getCounts = function() {
});
return counts;
};
-
-// Same interface as FileBytes, for reading from a buffer instead of a file.
-function BufferBytes(buf) {
- var bin = new BinArray(buf),
- bufSize = bin.size();
- this.readBytes = function(len, offset) {
- if (bufSize < offset + len) error("Out-of-range error");
- bin.position(offset);
- return bin;
- };
-
- this.size = function() {
- return bufSize;
- };
-
- this.close = function() {};
-}
-
-// Read a binary file in chunks, to support files > 1GB in Node
-function FileBytes(path) {
- var DEFAULT_BUF_SIZE = 0xffffff, // 16 MB
- fs = require('fs'),
- fileSize = cli.fileSize(path),
- cacheOffs = 0,
- cache, fd;
-
- this.readBytes = function(len, start) {
- if (fileSize < start + len) error("Out-of-range error");
- if (!cache || start < cacheOffs || start + len > cacheOffs + cache.size()) {
- updateCache(len, start);
- }
- cache.position(start - cacheOffs);
- return cache;
- };
-
- this.size = function() {
- return fileSize;
- };
-
- this.close = function() {
- if (fd) {
- fs.closeSync(fd);
- fd = null;
- cache = null;
- cacheOffs = 0;
- }
- };
-
- function updateCache(len, start) {
- var headroom = fileSize - start,
- bufSize = Math.min(headroom, Math.max(DEFAULT_BUF_SIZE, len)),
- buf = new Buffer(bufSize),
- bytesRead;
- if (!fd) fd = fs.openSync(path, 'r');
- bytesRead = fs.readSync(fd, buf, 0, bufSize, start);
- if (bytesRead < bufSize) error("Error reading file");
- cacheOffs = start;
- cache = new BinArray(buf);
- }
-}
diff --git a/src/shapefile/shp-record.js b/src/shapefile/shp-record.js
index d65c65c0c..207cdc241 100644
--- a/src/shapefile/shp-record.js
+++ b/src/shapefile/shp-record.js
@@ -165,6 +165,21 @@ function ShpRecordClass(type) {
if (xy.length != j) error('Counting error');
},
+ // TODO: consider switching to this simpler functino
+ stream2: function(sink) {
+ var sizes = this.readPartSizes(),
+ bin = this._data().skipBytes(this._xypos()),
+ i = 0, n;
+ while (i < sizes.length) {
+ n = sizes[i];
+ while (n-- > 0) {
+ sink.addPoint(bin.readFloat64(), bin.readFloat64());
+ }
+ sink.endPath();
+ i++;
+ }
+ },
+
read: function() {
var parts = [],
sizes = this.readPartSizes(),
diff --git a/src/simplify/mapshaper-keep-shapes.js b/src/simplify/mapshaper-keep-shapes.js
index b3a36da2d..55a7cf618 100644
--- a/src/simplify/mapshaper-keep-shapes.js
+++ b/src/simplify/mapshaper-keep-shapes.js
@@ -35,28 +35,18 @@ internal.protectShape = function(arcData, shape) {
if (!maxRing || maxRing.length === 0) {
// invald shape
verbose("[protectShape()] Invalid shape:", shape);
- } else if (maxRing.length == 1) {
- internal.protectIslandRing(arcData, maxRing);
} else {
- internal.protectMultiRing(arcData, maxRing);
+ internal.protectPolygonRing(arcData, maxRing);
}
};
-// Add two vertices to the ring to form a triangle.
-// Assuming that this will inflate the ring.
-// Consider using the function for multi-arc rings, which
-// calculates ring area...
-internal.protectIslandRing = function(arcData, ring) {
- var added = internal.lockMaxThreshold(arcData, ring);
- if (added == 1) {
- added += internal.lockMaxThreshold(arcData, ring);
- }
- if (added < 2) verbose("[protectIslandRing()] Failed on ring:", ring);
-};
-
-internal.protectMultiRing = function(arcData, ring) {
+// Re-inflate a polygon ring that has collapsed due to simplification by
+// adding points in reverse order of removal until polygon is inflated.
+internal.protectPolygonRing = function(arcData, ring) {
var zlim = arcData.getRetainedInterval(),
- minArea = 0, // 0.00000001, // Need to handle rounding error?
+ // use epsilon as min area instead of 0, in case f.p. rounding produces
+ // a positive area for a collapsed polygon.
+ minArea = 1e-10,
area, added;
arcData.setRetainedInterval(Infinity);
area = geom.getPlanarPathArea(ring, arcData);
diff --git a/src/simplify/mapshaper-post-simplify-repair.js b/src/simplify/mapshaper-post-simplify-repair.js
index 6162391f1..327468494 100644
--- a/src/simplify/mapshaper-post-simplify-repair.js
+++ b/src/simplify/mapshaper-post-simplify-repair.js
@@ -15,7 +15,7 @@ internal.postSimplifyRepair = function(arcs) {
countFixed = countPre > countPost ? countPre - countPost : 0,
msg;
if (countPre > 0) {
- msg = utils.format("[simplify] Repaired %'i intersection%s", countFixed,
+ msg = utils.format("Repaired %'i intersection%s", countFixed,
utils.pluralSuffix(countFixed));
if (countPost > 0) {
msg += utils.format("; %'i intersection%s could not be repaired", countPost,
diff --git a/src/simplify/mapshaper-simplify-pct.js b/src/simplify/mapshaper-simplify-pct.js
new file mode 100644
index 000000000..95ad73687
--- /dev/null
+++ b/src/simplify/mapshaper-simplify-pct.js
@@ -0,0 +1,43 @@
+
+// Returns a function for converting simplification ratio [0-1] to an interval value.
+// If the dataset is large, the value is an approximation (for speed while using slider)
+internal.getThresholdFunction = function(arcs) {
+ var size = arcs.getPointCount(),
+ nth = Math.ceil(size / 5e5),
+ sortedThresholds = arcs.getRemovableThresholds(nth);
+ // Sort simplification thresholds for all non-endpoint vertices
+ // for quick conversion of simplification percentage to threshold value.
+ // For large datasets, use every nth point, for faster sorting.
+ // utils.quicksort(sortedThresholds, false); // descending
+ utils.quicksort(sortedThresholds, true); // ascending
+
+ return function(pct) {
+ var n = sortedThresholds.length;
+ var rank = internal.retainedPctToRank(pct, sortedThresholds.length);
+ if (rank < 1) return 0;
+ if (rank > n) return Infinity;
+ return sortedThresholds[rank-1];
+ };
+};
+
+// Return integer rank of n (1-indexed) or 0 if pct <= 0 or n+1 if pct >= 1
+internal.retainedPctToRank = function(pct, n) {
+ var rank;
+ if (n === 0 || pct >= 1) {
+ rank = 0;
+ } else if (pct <= 0) {
+ rank = n + 1;
+ } else {
+ rank = Math.floor((1 - pct) * (n + 2));
+ }
+ return rank;
+};
+
+// nth (optional): sample every nth threshold (use estimate for speed)
+internal.getThresholdByPct = function(pct, arcs, nth) {
+ var tmp = arcs.getRemovableThresholds(nth),
+ rank = internal.retainedPctToRank(pct, tmp.length);
+ if (rank < 1) return 0;
+ if (rank > tmp.length) return Infinity;
+ return utils.findValueByRank(tmp, rank);
+};
diff --git a/src/simplify/mapshaper-simplify.js b/src/simplify/mapshaper-simplify.js
index df2aeeb29..6c7acf31a 100644
--- a/src/simplify/mapshaper-simplify.js
+++ b/src/simplify/mapshaper-simplify.js
@@ -9,22 +9,27 @@ mapshaper-simplify-info
api.simplify = function(dataset, opts) {
var arcs = dataset.arcs;
- if (!arcs) stop("[simplify] Missing path data");
+ if (!arcs) stop("Missing path data");
// standardize options
opts = internal.getStandardSimplifyOpts(dataset, opts);
- // stash simplifcation options (used by gui settings dialog)
- dataset.info = utils.defaults({simplify: opts}, dataset.info);
-
internal.simplifyPaths(arcs, opts);
- if (opts.percentage) {
+ // calculate and apply simplification interval
+ if (opts.percentage || opts.percentage === 0) {
arcs.setRetainedPct(utils.parsePercent(opts.percentage));
- } else if (utils.isNumber(opts.interval)) {
- arcs.setRetainedInterval(opts.interval);
+ } else if (opts.interval || opts.interval === 0) {
+ arcs.setRetainedInterval(internal.convertSimplifyInterval(opts.interval, dataset, opts));
} else if (opts.resolution) {
- arcs.setRetainedInterval(internal.calcSimplifyInterval(arcs, opts));
+ arcs.setRetainedInterval(internal.convertSimplifyResolution(opts.resolution, arcs, opts));
+ } else {
+ stop("Missing a simplification amount");
}
+ internal.finalizeSimplification(dataset, opts);
+};
+
+internal.finalizeSimplification = function(dataset, opts) {
+ var arcs = dataset.arcs;
if (opts.keep_shapes) {
api.keepEveryPolygon(arcs, dataset.layers);
}
@@ -36,6 +41,9 @@ api.simplify = function(dataset, opts) {
if (opts.stats) {
internal.printSimplifyInfo(arcs, opts);
}
+
+ // stash simplification options (used by gui settings dialog)
+ dataset.info = utils.defaults({simplify: opts}, dataset.info);
};
internal.getStandardSimplifyOpts = function(dataset, opts) {
@@ -103,7 +111,7 @@ internal.getSimplifyFunction = function(opts) {
} else if (opts.method == 'weighted_visvalingam') {
f = Visvalingam.getWeightedSimplifier(opts, opts.spherical);
} else {
- stop('[simplify] Unsupported simplify method:', method);
+ stop('Unsupported simplify method:', method);
}
return f;
};
@@ -171,7 +179,7 @@ internal.parseSimplifyResolution = function(raw) {
h = raw;
}
else if (utils.isString(raw)) {
- parts = raw.split('x');
+ parts = raw.split(/[x ,]/);
w = Number(parts[0]) || 0;
h = parts.length == 2 ? Number(parts[1]) || 0 : w;
}
@@ -197,21 +205,29 @@ internal.calcSphericalInterval = function(xres, yres, bounds) {
return internal.calcPlanarInterval(xres, yres, width, height);
};
-internal.calcSimplifyInterval = function(arcs, opts) {
- var res, interval, bounds;
- if (opts.interval) {
- interval = opts.interval;
- } else if (opts.resolution) {
- res = internal.parseSimplifyResolution(opts.resolution);
- bounds = arcs.getBounds();
- if (internal.useSphericalSimplify(arcs, opts)) {
- interval = internal.calcSphericalInterval(res[0], res[1], bounds);
- } else {
- interval = internal.calcPlanarInterval(res[0], res[1], bounds.width(), bounds.height());
- }
- // scale interval to double the resolution (single-pixel resolution creates
- // visible artefacts)
- interval *= 0.5;
+internal.convertSimplifyInterval = function(param, dataset, opts) {
+ var crs = internal.getDatasetCRS(dataset);
+ var interval;
+ if (internal.useSphericalSimplify(dataset.arcs, opts)) {
+ interval = internal.convertDistanceParam(param, crs);
+ } else {
+ interval = internal.convertIntervalParam(param, crs);
+ }
+ return interval;
+};
+
+// convert resolution to an interval
+internal.convertSimplifyResolution = function(param, arcs, opts) {
+ var res = internal.parseSimplifyResolution(param);
+ var bounds = arcs.getBounds();
+ var interval;
+ if (internal.useSphericalSimplify(arcs, opts)) {
+ interval = internal.calcSphericalInterval(res[0], res[1], bounds);
+ } else {
+ interval = internal.calcPlanarInterval(res[0], res[1], bounds.width(), bounds.height());
}
+ // scale interval to double the resolution (single-pixel resolution creates
+ // visible artifacts)
+ interval *= 0.5;
return interval;
};
diff --git a/src/simplify/mapshaper-variable-simplify.js b/src/simplify/mapshaper-variable-simplify.js
new file mode 100644
index 000000000..e3453a660
--- /dev/null
+++ b/src/simplify/mapshaper-variable-simplify.js
@@ -0,0 +1,108 @@
+/* @requires mapshaper-simplify-pct */
+
+api.variableSimplify = function(layers, dataset, opts) {
+ var lyr = layers[0];
+ var arcs = dataset.arcs;
+ var getShapeThreshold;
+ var arcThresholds;
+ if (layers.length != 1) {
+ stop('Variable simplification requires a single target layer');
+ }
+ if (!internal.layerHasPaths(lyr)) {
+ stop('Target layer is missing path data');
+ }
+
+ opts = internal.getStandardSimplifyOpts(dataset, opts);
+ internal.simplifyPaths(arcs, opts);
+
+ if (opts.interval) {
+ getShapeThreshold = internal.getVariableIntervalFunction(opts.interval, lyr, dataset, opts);
+ } else if (opts.percentage) {
+ getShapeThreshold = internal.getVariablePercentageFunction(opts.percentage, lyr, dataset, opts);
+ } else if (opts.resolution) {
+ getShapeThreshold = internal.getVariableResolutionFunction(opts.resolution, lyr, dataset, opts);
+ } else {
+ stop("Missing a simplification expression");
+ }
+
+ arcThresholds = internal.calculateVariableThresholds(lyr, arcs, getShapeThreshold);
+ internal.applyArcThresholds(arcs, arcThresholds);
+ arcs.setRetainedInterval(1e20); // set to a huge value
+ internal.finalizeSimplification(dataset, opts);
+ arcs.flatten(); // bake in simplification (different from standard -simplify)
+};
+
+internal.getVariableIntervalFunction = function(exp, lyr, dataset, opts) {
+ var compiled = internal.compileSimplifyExpression(exp, lyr, dataset.arcs);
+ return function(shpId) {
+ var val = compiled(shpId);
+ return internal.convertSimplifyInterval(val, dataset, opts);
+ };
+};
+
+internal.getVariableResolutionFunction = function(exp, lyr, dataset, opts) {
+ var compiled = internal.compileSimplifyExpression(exp, lyr, dataset.arcs);
+ return function(shpId) {
+ var val = compiled(shpId);
+ return internal.convertSimplifyResolution(val, dataset.arcs, opts);
+ };
+};
+
+internal.getVariablePercentageFunction = function(exp, lyr, dataset, opts) {
+ var compiled = internal.compileSimplifyExpression(exp, lyr, dataset.arcs);
+ var pctToInterval = internal.getThresholdFunction(dataset.arcs);
+ return function(shpId) {
+ var val = compiled(shpId);
+ var pct = utils.parsePercent(val);
+ return pctToInterval(pct);
+ };
+};
+
+// TODO: memoize?
+internal.compileSimplifyExpression = function(exp, lyr, arcs) {
+ return internal.compileValueExpression(exp, lyr, arcs);
+};
+
+// Filter arcs based on an array of thresholds
+internal.applyArcThresholds = function(arcs, thresholds) {
+ var zz = arcs.getVertexData().zz;
+ arcs.forEach2(function(start, n, xx, yy, zz, arcId) {
+ var arcZ = thresholds[arcId];
+ var z;
+ for (var i=1; i= arcZ || arcZ === Infinity) { // Infinity test is a bug
+ if (z >= arcZ) {
+ // protect vertices with thresholds that are >= than the computed threshold
+ // for this arc
+ zz[start + i] = Infinity;
+ }
+ }
+ });
+};
+
+internal.calculateVariableThresholds = function(lyr, arcs, getShapeThreshold) {
+ var thresholds = new Float64Array(arcs.size()); // init to 0s
+ var UNUSED = -1;
+ var currThresh;
+ utils.initializeArray(thresholds, UNUSED);
+ lyr.shapes.forEach(function(shp, shpId) {
+ currThresh = getShapeThreshold(shpId);
+ internal.forEachArcId(shp || [], procArc);
+ });
+ // set unset arcs to 0 so they are not simplified
+ for (var i=0, n=thresholds.length; i currThresh || savedThresh == UNUSED) {
+ thresholds[i] = currThresh;
+ }
+ }
+};
diff --git a/src/svg/geojson-to-svg.js b/src/svg/geojson-to-svg.js
index 2160bf2c5..ab958701b 100644
--- a/src/svg/geojson-to-svg.js
+++ b/src/svg/geojson-to-svg.js
@@ -1,16 +1,18 @@
-/* @requires geojson-common, mapshaper-svg-style */
+/* @requires geojson-common, svg-common, mapshaper-svg-style, svg-stringify */
-var SVG = {};
-
-SVG.importGeoJSONFeatures = function(features) {
+SVG.importGeoJSONFeatures = function(features, opts) {
+ opts = opts || {};
return features.map(function(obj, i) {
var geom = obj.type == 'Feature' ? obj.geometry : obj; // could be null
var geomType = geom && geom.type;
- var svgObj;
- if (!geomType) {
+ var svgObj = null;
+ if (geomType && geom.coordinates) {
+ svgObj = SVG.geojsonImporters[geomType](geom.coordinates, obj.properties, opts);
+ }
+ if (!svgObj) {
return {tag: 'g'}; // empty element
}
- svgObj = SVG.geojsonImporters[geomType](geom.coordinates);
+ // TODO: fix error caused by null svgObj (caused by e.g. MultiPolygon with [] coordinates)
if (obj.properties) {
SVG.applyStyleAttributes(svgObj, geomType, obj.properties);
}
@@ -18,90 +20,47 @@ SVG.importGeoJSONFeatures = function(features) {
if (!svgObj.properties) {
svgObj.properties = {};
}
- svgObj.properties.id = obj.id;
+ svgObj.properties.id = (opts.id_prefix || '') + obj.id;
}
return svgObj;
});
};
-SVG.stringify = function(obj) {
- var svg = '<' + obj.tag;
- if (obj.properties) {
- svg += SVG.stringifyProperties(obj.properties);
- }
- if (obj.children) {
- svg += '>\n';
- svg += obj.children.map(SVG.stringify).join('\n');
- svg += '\n' + obj.tag + '>';
- } else {
- svg += '/>';
- }
- return svg;
-};
-
-SVG.stringEscape = (function() {
- // See http://commons.oreilly.com/wiki/index.php/SVG_Essentials/The_XML_You_Need_for_SVG
- var rxp = /[&<>"']/g,
- map = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": '''
- };
- return function(s) {
- return String(s).replace(rxp, function(s) {
- return map[s];
- });
- };
-}());
-
-SVG.stringifyProperties = function(o) {
- return Object.keys(o).reduce(function(memo, key, i) {
- var val = o[key],
- strval = utils.isString(val) ? val : JSON.stringify(val);
- return memo + ' ' + key + '="' + SVG.stringEscape(strval) + '"';
- }, '');
-};
-
-
SVG.applyStyleAttributes = function(svgObj, geomType, rec) {
- var properties = svgObj.properties;
- var invalidStyles = internal.invalidSvgTypes[GeoJSON.translateGeoJSONType(geomType)];
- var fields = internal.getStyleFields(Object.keys(rec), internal.svgStyles, invalidStyles);
- var k;
+ var symbolType = GeoJSON.translateGeoJSONType(geomType);
+ if (symbolType == 'point' && ('label-text' in rec)) {
+ symbolType = 'label';
+ }
+ var fields = SVG.findPropertiesBySymbolGeom(Object.keys(rec), symbolType);
for (var i=0, n=fields.length; i 0 ? {tag: 'g', children: children} : null;
};
SVG.importMultiPath = function(coords, importer) {
@@ -117,7 +76,7 @@ SVG.importMultiPath = function(coords, importer) {
};
SVG.mapVertex = function(p) {
- return p[0] + ' ' + -p[1];
+ return p[0] + ' ' + p[1];
};
SVG.importLineString = function(coords) {
@@ -128,14 +87,65 @@ SVG.importLineString = function(coords) {
};
};
-SVG.importPoint = function(p) {
- return {
- tag: 'circle',
- properties: {
- cx: p[0],
- cy: -p[1]
- }
+// Kludge for applying fill and other styles to a element
+// (for rendering labels in the GUI with the dot in Canvas, not SVG)
+SVG.importStyledLabel = function(rec, p) {
+ var o = SVG.importLabel(rec, p);
+ SVG.applyStyleAttributes(o, 'Point', rec);
+ return o;
+};
+
+SVG.importLabel = function(rec, p) {
+ var line = rec['label-text'] || '';
+ var morelines, obj;
+ // Accepting \n (two chars) as an alternative to the newline character
+ // (sometimes, '\n' is not converted to newline, e.g. in a Makefile)
+ // Also accepting
+ var newline = /\n|\\n|
/i;
+ var dx = rec.dx || 0;
+ var dy = rec.dy || 0;
+ var properties = {
+ // using x, y instead of dx, dy for shift, because Illustrator doesn't apply
+ // dx value when importing text with text-anchor=end
+ y: dy,
+ x: dx
};
+ if (p) {
+ properties.transform = SVG.getTransform(p);
+ }
+ if (newline.test(line)) {
+ morelines = line.split(newline);
+ line = morelines.shift();
+ }
+ obj = {
+ tag: 'text',
+ value: line,
+ properties: properties
+ };
+ if (morelines) {
+ // multiline label
+ obj.children = [];
+ morelines.forEach(function(line) {
+ var tspan = {
+ tag: 'tspan',
+ value: line,
+ properties: {
+ x: dx,
+ dy: rec['line-height'] || '1.1em'
+ }
+ };
+ obj.children.push(tspan);
+ });
+ }
+ return obj;
+};
+
+SVG.importPoint = function(coords, rec, layerOpts) {
+ rec = rec || {};
+ if ('svg-symbol' in rec) {
+ return SVG.importSymbol(rec['svg-symbol'], coords);
+ }
+ return SVG.importStandardPoint(coords, rec, layerOpts || {});
};
SVG.importPolygon = function(coords) {
@@ -148,12 +158,70 @@ SVG.importPolygon = function(coords) {
return o;
};
+SVG.importStandardPoint = function(coords, rec, layerOpts) {
+ var isLabel = 'label-text' in rec;
+ var symbolType = layerOpts.point_symbol || '';
+ var children = [];
+ var halfSize = rec.r || 0; // radius or half of symbol size
+ var deg2rad = Math.PI / 180;
+ var symbolRotate = (+rec.rotate || 0) * deg2rad; // rotation applied on symbol around point's center
+ var p;
+ // if not a label, create a symbol even without a size
+ // (circle radius can be set via CSS)
+ if (halfSize > 0 || !isLabel) {
+ if (symbolType == 'square') {
+ var squareRotate = symbolRotate + Math.PI * .25 // Rotate by 45 degrees to make the square sit straight
+ var squareLength = halfSize * Math.sqrt(2) // From the half-diagonal, get square's length
+ p = {
+ tag: 'polygon',
+ properties: {
+ points: [
+ [coords[0] + squareLength * Math.cos(squareRotate), coords[1] + squareLength * Math.sin(squareRotate)],
+ [coords[0] + squareLength * Math.cos(squareRotate + Math.PI * .5), coords[1] + squareLength * Math.sin(squareRotate + Math.PI * .5)],
+ [coords[0] + squareLength * Math.cos(squareRotate + Math.PI), coords[1] + squareLength * Math.sin(squareRotate + Math.PI)],
+ [coords[0] + squareLength * Math.cos(squareRotate + Math.PI * -.5), coords[1] + squareLength * Math.sin(squareRotate + Math.PI * -.5)]
+ ]
+ .map(function(pt) {
+ return pt.join(',') // joining two dimensions of point
+ })
+ .join(',') // join each point string coordinates
+ }
+ };
+ } else if (symbolType == 'line') {
+ var lineLength = Math.pow(halfSize, 2) //
+ p = {
+ tag: 'line',
+ properties: {
+ x1: coords[0],
+ y1: coords[1],
+ x2: coords[0] + lineLength * Math.cos(symbolRotate),
+ y2: coords[1] + lineLength * Math.sin(symbolRotate)
+ }};
+ } else { // default is circle
+ p = {
+ tag: 'circle',
+ properties: {
+ cx: coords[0],
+ cy: coords[1]
+ }};
+ if (halfSize > 0) {
+ p.properties.r = halfSize;
+ }
+ }
+ children.push(p);
+ }
+ if (isLabel) {
+ children.push(SVG.importLabel(rec, coords));
+ }
+ return children.length > 1 ? {tag: 'g', children: children} : children[0];
+};
+
SVG.geojsonImporters = {
Point: SVG.importPoint,
Polygon: SVG.importPolygon,
LineString: SVG.importLineString,
- MultiPoint: function(coords) {
- return SVG.importMultiGeometry(coords, SVG.importPoint);
+ MultiPoint: function(coords, rec, opts) {
+ return SVG.importMultiPoint(coords, rec, opts);
},
MultiLineString: function(coords) {
return SVG.importMultiPath(coords, SVG.importLineString);
diff --git a/src/svg/mapshaper-basic-symbols.js b/src/svg/mapshaper-basic-symbols.js
new file mode 100644
index 000000000..c13cf1274
--- /dev/null
+++ b/src/svg/mapshaper-basic-symbols.js
@@ -0,0 +1,84 @@
+/* @require svg-common */
+
+SVG.symbolRenderers.circle = function(d, x, y) {
+ var o = SVG.importPoint([x, y], d, {});
+ SVG.applyStyleAttributes(o, 'Point', d);
+ return [o];
+};
+
+SVG.symbolRenderers.label = function(d, x, y) {
+ var o = SVG.importStyledLabel(d, [x, y]);
+ return [o];
+};
+
+SVG.symbolRenderers.image = function(d, x, y) {
+ var w = d.width || 20,
+ h = d.height || 20;
+ var o = {
+ tag: 'image',
+ properties: {
+ width: w,
+ height: h,
+ x: x - w / 2,
+ y: y - h / 2,
+ href: d.href || ''
+ }
+ };
+ return [o];
+};
+
+SVG.symbolRenderers.square = function(d, x, y) {
+ var o = SVG.importPoint([x, y], d, {point_symbol: 'square'});
+ SVG.applyStyleAttributes(o, 'Point', d);
+ return [o];
+};
+
+SVG.symbolRenderers.line = function(d, x, y) {
+ var coords, o;
+ coords = [[x, y], [x + (d.dx || 0), y + (d.dy || 0)]];
+ o = SVG.importLineString(coords);
+ SVG.applyStyleAttributes(o, 'LineString', d);
+ return [o];
+};
+
+SVG.symbolRenderers.group = function(d, x, y) {
+ return (d.parts || []).reduce(function(memo, o) {
+ var sym = SVG.renderSymbol(o, x, y);
+ if (d.chained) {
+ x += (o.dx || 0);
+ y += (o.dy || 0);
+ }
+ return memo.concat(sym);
+ }, []);
+};
+
+SVG.getEmptySymbol = function() {
+ return {tag: 'g', properties: {}, children: []};
+};
+
+SVG.renderSymbol = function(d, x, y) {
+ var renderer = SVG.symbolRenderers[d.type];
+ if (!renderer) {
+ stop(d.type ? 'Unknown symbol type: ' + d.type : 'Symbol is missing a type property');
+ }
+ return renderer(d, x || 0, y || 0);
+};
+
+// d: svg-symbol object from feature data object
+SVG.importSymbol = function(d, xy) {
+ var renderer;
+ if (!d) {
+ return SVG.getEmptySymbol();
+ }
+ if (utils.isString(d)) {
+ d = JSON.parse(d);
+ }
+ return {
+ tag: 'g',
+ properties: {
+ 'class': 'mapshaper-svg-symbol',
+ transform: xy ? SVG.getTransform(xy) : null
+ },
+ children: SVG.renderSymbol(d)
+ };
+};
diff --git a/src/svg/mapshaper-scalebar.js b/src/svg/mapshaper-scalebar.js
new file mode 100644
index 000000000..70a786c81
--- /dev/null
+++ b/src/svg/mapshaper-scalebar.js
@@ -0,0 +1,142 @@
+/* @require svg-common mapshaper-frame */
+
+api.scalebar = function(catalog, opts) {
+ var frame = internal.findFrameDataset(catalog);
+ var obj, lyr;
+ if (!frame) {
+ stop('Missing a map frame');
+ }
+ obj = utils.defaults({type: 'scalebar'}, opts);
+ lyr = {
+ name: opts.name || 'scalebar',
+ data: new DataTable([obj])
+ };
+ frame.layers.push(lyr);
+};
+
+// TODO: generalize to other kinds of furniture as they are developed
+internal.getScalebarPosition = function(d) {
+ var opts = { // defaults
+ valign: 'top',
+ halign: 'left',
+ voffs: 10,
+ hoffs: 10
+ };
+ if (+d.left > 0) {
+ opts.hoffs = +d.left;
+ }
+ if (+d.top > 0) {
+ opts.voffs = +d.top;
+ }
+ if (+d.right > 0) {
+ opts.hoffs = +d.right;
+ opts.halign = 'right';
+ }
+ if (+d.bottom > 0) {
+ opts.voffs = +d.bottom;
+ opts.valign = 'bottom';
+ }
+ return opts;
+};
+
+SVG.furnitureRenderers.scalebar = function(d, frame) {
+ var pos = internal.getScalebarPosition(d);
+ var metersPerPx = internal.getMapFrameMetersPerPixel(frame);
+ var label = d.label_text || internal.getAutoScalebarLabel(frame.width, metersPerPx);
+ var scalebarKm = internal.parseScalebarLabelToKm(label);
+ var barHeight = 3;
+ var labelOffs = 4;
+ var fontSize = +d.font_size || 13;
+ var width = Math.round(scalebarKm / metersPerPx * 1000);
+ var height = Math.round(barHeight + labelOffs + fontSize * 0.8);
+ var labelPos = d.label_position == 'top' ? 'top' : 'bottom';
+ var anchorX = pos.halign == 'left' ? 0 : width;
+ var anchorY = barHeight + labelOffs;
+ var dx = pos.halign == 'right' ? frame.width - width - pos.hoffs : pos.hoffs;
+ var dy = pos.valign == 'bottom' ? frame.height - height - pos.voffs : pos.voffs;
+
+ if (labelPos == 'top') {
+ anchorY = -labelOffs;
+ dy += Math.round(labelOffs + fontSize * 0.8);
+ }
+
+ if (width > 0 === false) {
+ stop("Null scalebar length");
+ }
+ var barObj = {
+ tag: 'rect',
+ properties: {
+ fill: 'black',
+ x: 0,
+ y: 0,
+ width: width,
+ height: barHeight
+ }
+ };
+ var labelOpts = {
+ 'label-text': label,
+ 'font-size': fontSize,
+ 'text-anchor': pos.halign == 'left' ? 'start': 'end',
+ 'dominant-baseline': labelPos == 'top' ? 'auto' : 'hanging'
+ //// 'dominant-baseline': labelPos == 'top' ? 'text-after-edge' : 'text-before-edge'
+ // 'text-after-edge' is buggy in Safari and unsupported by Illustrator,
+ // so I'm using 'hanging' and 'auto', which seem to be well supported.
+ // downside: requires a kludgy multiplier to calculate scalebar height (see above)
+ };
+ var labelObj = SVG.symbolRenderers.label(labelOpts, anchorX, anchorY)[0];
+ var g = {
+ tag: 'g',
+ children: [barObj, labelObj],
+ properties: {
+ transform: 'translate(' + dx + ' ' + dy + ')'
+ }
+ };
+ return [g];
+};
+
+internal.getAutoScalebarLabel = function(mapWidth, metersPerPx) {
+ var minWidth = 100; // TODO: vary min size based on map width
+ var minKm = metersPerPx * minWidth / 1000;
+ var options = ('1/8 1/5 1/4 1/2 1 1.5 2 3 4 5 8 10 12 15 20 25 30 40 50 75 ' +
+ '100 150 200 250 300 350 400 500 750 1,000 1,200 1,500 2,000 ' +
+ '2,500 3,000 4,000 5,000').split(' ');
+ return options.reduce(function(memo, str) {
+ if (memo) return memo;
+ var label = internal.formatDistanceLabelAsMiles(str);
+ if (internal.parseScalebarLabelToKm(label) > minKm) {
+ return label;
+ }
+ }, null) || '';
+};
+
+internal.formatDistanceLabelAsMiles = function(str) {
+ var num = internal.parseScalebarNumber(str);
+ return str + (num > 1 ? ' MILES' : ' MILE');
+};
+
+// See test/mapshaper-scalebar.js for examples of supported formats
+internal.parseScalebarLabelToKm = function(str) {
+ var units = internal.parseScalebarUnits(str);
+ var value = internal.parseScalebarNumber(str);
+ if (!units || !value) return NaN;
+ return units == 'mile' ? value * 1.60934 : value;
+};
+
+internal.parseScalebarUnits = function(str) {
+ var isMiles = /miles?$/.test(str.toLowerCase());
+ var isKm = /(km|kilometers?|kilometres?)$/.test(str.toLowerCase());
+ return isMiles && 'mile' || isKm && 'km' || '';
+};
+
+internal.parseScalebarNumber = function(str) {
+ var fractionRxp = /^([0-9]+) ?\/ ?([0-9]+)/;
+ var match, value;
+ str = str.replace(/[\s]/g, '').replace(/,/g, '');
+ if (fractionRxp.test(str)) {
+ match = fractionRxp.exec(str);
+ value = +match[1] / +match[2];
+ } else {
+ value = parseFloat(str);
+ }
+ return value > 0 && value < Infinity ? value : NaN;
+};
diff --git a/src/svg/mapshaper-svg.js b/src/svg/mapshaper-svg.js
index 834e677b6..ea752e339 100644
--- a/src/svg/mapshaper-svg.js
+++ b/src/svg/mapshaper-svg.js
@@ -1,17 +1,21 @@
/* @requires
mapshaper-common
+mapshaper-basic-symbols
geojson-export
-geojson-points
geojson-to-svg
mapshaper-svg-style
+svg-common
+mapshaper-pixel-transform
*/
//
//
internal.exportSVG = function(dataset, opts) {
- var template = '\n';
- var b, svg;
+ var namespace = 'xmlns="http://www.w3.org/2000/svg"';
+ var symbols = [];
+ var size, svg;
// TODO: consider moving this logic to mapshaper-export.js
if (opts.final) {
@@ -19,12 +23,19 @@ internal.exportSVG = function(dataset, opts) {
} else {
dataset = internal.copyDataset(dataset); // Modify a copy of the dataset
}
-
- b = internal.transformCoordsForSVG(dataset, opts);
+ // invert_y setting for screen coordinates and geojson polygon generation
+ utils.extend(opts, {invert_y: true});
+ size = internal.transformCoordsForSVG(dataset, opts);
svg = dataset.layers.map(function(lyr) {
- return internal.exportLayerAsSVG(lyr, dataset, opts);
+ var obj = internal.exportLayerForSVG(lyr, dataset, opts);
+ SVG.embedImages(obj, symbols);
+ return SVG.stringify(obj);
}).join('\n');
- svg = utils.format(template, b.width(), b.height(), 0, 0, b.width(), b.height(), svg);
+ if (symbols.length > 0) {
+ namespace += ' xmlns:xlink="http://www.w3.org/1999/xlink"';
+ svg = '\n' + utils.pluck(symbols, 'svg').join('') + '\n' + svg;
+ }
+ svg = utils.format(template, namespace, size[0], size[1], 0, 0, size[0], size[1], svg);
return [{
content: svg,
filename: opts.file || utils.getOutputFileBase(dataset) + '.svg'
@@ -32,69 +43,72 @@ internal.exportSVG = function(dataset, opts) {
};
internal.transformCoordsForSVG = function(dataset, opts) {
- var width = opts.width > 0 ? opts.width : 800;
- var margin = opts.margin >= 0 ? opts.margin : 1;
- var bounds = internal.getDatasetBounds(dataset);
+ var size = internal.transformDatasetToPixels(dataset, opts);
var precision = opts.precision || 0.0001;
- var height, bounds2, fwd;
-
- if (opts.svg_scale > 0) {
- // alternative to using a fixed width (e.g. when generating multiple files
- // at a consistent geographic scale)
- width = bounds.width() / opts.svg_scale;
- margin = 0;
- }
- internal.padViewportBoundsForSVG(bounds, width, margin);
- height = Math.ceil(width * bounds.height() / bounds.width());
- bounds2 = new Bounds(0, -height, width, 0);
- fwd = bounds.getTransform(bounds2);
- internal.transformPoints(dataset, function(x, y) {
- return fwd.transform(x, y);
- });
-
internal.setCoordinatePrecision(dataset, precision);
- return bounds2;
+ return size;
};
-// pad bounds to accomodate stroke width and circle radius
-internal.padViewportBoundsForSVG = function(bounds, width, marginPx) {
- var bw = bounds.width() || bounds.height() || 1; // handle 0 width bbox
- var marg;
- if (marginPx >= 0 === false) {
- marginPx = 1;
+internal.exportLayerForSVG = function(lyr, dataset, opts) {
+ var layerObj = internal.getEmptyLayerForSVG(lyr, opts);
+ if (internal.layerHasFurniture(lyr)) {
+ layerObj.children = internal.exportFurnitureForSVG(lyr, dataset, opts);
+ } else {
+ layerObj.children = internal.exportSymbolsForSVG(lyr, dataset, opts);
}
- marg = bw / (width - marginPx * 2) * marginPx;
- bounds.padBounds(marg, marg, marg, marg);
+ return layerObj;
};
-internal.exportGeoJSONForSVG = function(lyr, dataset, opts) {
- var geojson = internal.exportGeoJSONCollection(lyr, dataset, opts);
- if (opts.point_symbol == 'square' && geojson.features) {
- geojson.features.forEach(function(feat) {
- GeoJSON.convertPointFeatureToSquare(feat, 'r', 2);
- });
- }
- return geojson;
+internal.exportFurnitureForSVG = function(lyr, dataset, opts) {
+ var frameLyr = internal.findFrameLayerInDataset(dataset);
+ var frameData;
+ if (!frameLyr) return [];
+ frameData = internal.getFurnitureLayerData(frameLyr);
+ frameData.crs = internal.getDatasetCRS(dataset); // required by e.g. scalebar
+ return SVG.importFurniture(internal.getFurnitureLayerData(lyr), frameData);
};
-internal.exportLayerAsSVG = function(lyr, dataset, opts) {
+internal.exportSymbolsForSVG = function(lyr, dataset, opts) {
// TODO: convert geojson features one at a time
- var geojson = internal.exportGeoJSONForSVG(lyr, dataset, opts);
+ var d = utils.defaults({layers: [lyr]}, dataset);
+ var geojson = internal.exportDatasetAsGeoJSON(d, opts);
var features = geojson.features || geojson.geometries || (geojson.type ? [geojson] : []);
- var symbols = SVG.importGeoJSONFeatures(features);
+ return SVG.importGeoJSONFeatures(features, opts);
+};
+
+internal.getEmptyLayerForSVG = function(lyr, opts) {
var layerObj = {
tag: 'g',
- children: symbols,
- properties: {id: lyr.name}
+ properties: {id: (opts.id_prefix || '') + lyr.name},
+ children: []
};
// add default display properties to line layers
- // (these are overridden by feature-level styles set via -svg-style)
+ // (these are overridden by feature-level styles set via -style)
if (lyr.geometry_type == 'polyline') {
layerObj.properties.fill = 'none';
layerObj.properties.stroke = 'black';
layerObj.properties['stroke-width'] = 1;
}
- return SVG.stringify(layerObj);
+ // add default text properties to layers with labels
+ if (internal.layerHasLabels(lyr) || internal.layerHasSvgSymbols(lyr) || internal.layerHasFurniture(lyr)) {
+ layerObj.properties['font-family'] = 'sans-serif';
+ layerObj.properties['font-size'] = '12';
+ layerObj.properties['text-anchor'] = 'middle';
+ }
+
+ return layerObj;
+};
+
+internal.layerHasSvgSymbols = function(lyr) {
+ return lyr.geometry_type == 'point' && lyr.data && lyr.data.fieldExists('svg-symbol');
+};
+
+internal.layerHasLabels = function(lyr) {
+ var hasLabels = lyr.geometry_type == 'point' && lyr.data && lyr.data.fieldExists('label-text');
+ //if (hasLabels && internal.findMaxPartCount(lyr.shapes) > 1) {
+ // console.error('Multi-point labels are not fully supported');
+ //}
+ return hasLabels;
};
diff --git a/src/svg/svg-common.js b/src/svg/svg-common.js
new file mode 100644
index 000000000..bead2cd8b
--- /dev/null
+++ b/src/svg/svg-common.js
@@ -0,0 +1,46 @@
+
+var SVG = {};
+
+SVG.propertyTypes = {
+ class: 'classname',
+ opacity: 'number',
+ r: 'number',
+ dx: 'measure',
+ dy: 'measure',
+ fill: 'color',
+ stroke: 'color',
+ 'line-height': 'measure',
+ 'letter-spacing': 'measure',
+ 'stroke-width': 'number',
+ 'stroke-dasharray': 'dasharray',
+ rotate: 'number'
+};
+
+SVG.symbolRenderers = {};
+SVG.furnitureRenderers = {};
+
+SVG.supportedProperties = 'class,opacity,stroke,stroke-width,stroke-dasharray,fill,r,dx,dy,font-family,font-size,text-anchor,font-weight,font-style,line-height,letter-spacing,rotate'.split(',');
+SVG.commonProperties = 'class,opacity,stroke,stroke-width,stroke-dasharray'.split(',');
+
+SVG.propertiesBySymbolType = {
+ polygon: utils.arrayToIndex(SVG.commonProperties.concat('fill')),
+ polyline: utils.arrayToIndex(SVG.commonProperties),
+ point: utils.arrayToIndex(SVG.commonProperties.concat('fill', 'r', 'rotate')),
+ label: utils.arrayToIndex(SVG.commonProperties.concat(
+ 'fill,r,font-family,font-size,text-anchor,font-weight,font-style,letter-spacing,dominant-baseline'.split(',')))
+};
+
+SVG.findPropertiesBySymbolGeom = function(fields, type) {
+ var index = SVG.propertiesBySymbolType[type] || {};
+ return fields.filter(function(name) {
+ return name in index;
+ });
+};
+
+SVG.getTransform = function(xy, scale) {
+ var str = 'translate(' + xy[0] + ' ' + xy[1] + ')';
+ if (scale && scale != 1) {
+ str += ' scale(' + scale + ')';
+ }
+ return str;
+};
diff --git a/src/svg/svg-stringify.js b/src/svg/svg-stringify.js
new file mode 100644
index 000000000..8c700715e
--- /dev/null
+++ b/src/svg/svg-stringify.js
@@ -0,0 +1,137 @@
+/* @require svg-common mapshaper-sha1 */
+
+SVG.embedImages = function(obj, symbols) {
+ // Same-origin policy is an obstacle to embedding images in web UI
+ if (internal.runningInBrowser()) return;
+ procNode(obj);
+
+ function procNode(obj) {
+ if (obj.tag == 'image') {
+ if (/\.svg/.test(obj.properties.href || '')) {
+ embedSvgImage(obj);
+ }
+ } else if (obj.children) {
+ obj.children.forEach(procNode);
+ }
+ }
+
+ function embedSvgImage(obj) {
+ var id = addImage(obj.properties.href);
+ obj.tag = 'use';
+ obj.properties.href = '#' + id;
+ }
+
+ function addImage(href) {
+ var item = utils.find(symbols, function(item) {return item.href == href;});
+ if (!item) {
+ item = {
+ href: href,
+ id: SVG.urlToId(href) // generating id from href, to try to support multiple inline svgs on page
+ };
+ // item.svg = convertSvgToSymbol(getSvgFile(href), item.id) + '\n';
+ item.svg = convertSvg(getSvgFile(href), item.id) + '\n';
+ symbols.push(item);
+ }
+ return item.id;
+ }
+
+ function getSvgFile(href) {
+ var res, content, fs;
+ if (href.indexOf('http') === 0) {
+ res = require('sync-request')('GET', href, {timeout: 1000});
+ content = res.getBody().toString();
+ } else if (require('fs').existsSync(href)) { // assume href is a relative path
+ content = require('fs').readFileSync(href, 'utf8');
+ } else {
+ stop("Invalid SVG location:", href);
+ }
+ return content;
+ }
+
+ /*
+ function convertSvgToSymbol(svg, id) {
+ svg = svg.replace(/[^]*