diff --git a/mapshaper-gui.js b/mapshaper-gui.js index 12d74d4f..5d465047 100644 --- a/mapshaper-gui.js +++ b/mapshaper-gui.js @@ -2578,6 +2578,25 @@ internal.snapVerticesToPoint(ids, lyr.invertPoint(p[0], p[1]), lyr.source.dataset.arcs, true); } + function setRectangleCoords(lyr, ids, coords) { + ids.forEach(function(id, i) { + var p = coords[i]; + internal.snapVerticesToPoint([id], p, lyr.source.dataset.arcs, true); + if (isProjectedLayer(lyr)) { + internal.snapVerticesToPoint([id], lyr.projectPoint(p[0], p[1]), lyr.arcs, true); + } + }); + } + + // lyr: display layer + // export function updateRectangleCoords(lyr, ids, coords) { + // if (!isProjectedLayer(lyr)) return; + // ids.forEach(function(id, i) { + // var p = coords[i]; + // internal.snapVerticesToPoint([id], lyr.invertPoint(p[0], p[1]), lyr.source.dataset.arcs, true); + // }); + // } + function isProjectedLayer(lyr) { // TODO: could do some validation on the layer's contents return !!(lyr.source && lyr.invertPoint); @@ -4852,6 +4871,7 @@ var menus = { standard: ['info', 'selection', 'data', 'box'], polygons: ['info', 'selection', 'data', 'box', 'vertices'], + rectangles: ['info', 'selection', 'data', 'box', 'rectangles', 'vertices'], lines: ['info', 'selection', 'data', 'box', 'vertices'], table: ['info', 'selection', 'data'], labels: ['info', 'selection', 'data', 'box', 'labels', 'location'], @@ -4875,6 +4895,7 @@ vertices: 'edit vertices', selection: 'select features', 'add-points': 'add points', + rectangles: 'drag-to-resize', off: 'turn off' }; var btn, menu; @@ -4929,11 +4950,11 @@ }; this.modeUsesSelection = function(mode) { - return ['info', 'selection', 'data', 'labels', 'location', 'vertices'].includes(mode); + return ['info', 'selection', 'data', 'labels', 'location', 'vertices', 'rectangles'].includes(mode); }; this.modeUsesPopup = function(mode) { - return ['info', 'selection', 'data', 'box', 'labels', 'location'].includes(mode); + return ['info', 'selection', 'data', 'box', 'labels', 'location', 'rectangles'].includes(mode); }; this.getMode = getInteractionMode; @@ -4975,7 +4996,8 @@ return menus.lines; } if (internal.layerHasPaths(o.layer) && o.layer.geometry_type == 'polygon') { - return menus.polygons; + return internal.layerOnlyHasRectangles(o.layer, o.dataset.arcs) ? + menus.rectangles : menus.polygons; } return menus.standard; } @@ -7029,7 +7051,8 @@ function clickable() { // click used to pin popup and select features - return interactionMode == 'data' || interactionMode == 'info' || interactionMode == 'selection'; + return interactionMode == 'data' || interactionMode == 'info' || + interactionMode == 'selection' || interactionMode == 'rectangles'; } self.getHitId = function() {return storedData.id;}; @@ -7787,6 +7810,10 @@ _on = false, handles; + if (opts.classname) { + el.addClass(opts.classname); + } + el.hide(); gui.on('map_rendered', function() { @@ -7857,6 +7884,11 @@ }); } + box.setDataCoords = function(bbox) { + boxCoords = bbox; + redraw(); + }; + box.getDataCoords = function() { if (!boxCoords) return null; var dataBox = getBBoxCoords(gui.map.getActiveLayer(), boxCoords); @@ -7883,8 +7915,8 @@ props = { top: Math.min(y1, y2), left: Math.min(x1, x2), - width: Math.max(w - stroke * 2, 1), - height: Math.max(h - stroke * 2, 1) + width: Math.max(w - stroke / 2, 1), + height: Math.max(h - stroke / 2, 1) }; el.css(props); el.show(); @@ -9322,7 +9354,10 @@ var inSelection = o.ids.indexOf(o.id) > -1; var geomType = lyr.geometry_type; var style; - if (isPinned) { + if (isPinned && o.mode == 'rectangles') { + // kludge for rectangle editing mode + style = selectionStyles[geomType]; + } else if (isPinned) { // a feature is pinned style = pinnedStyles[geomType]; } else if (inSelection) { @@ -10682,6 +10717,82 @@ return self; } + function RectangleControl(gui, hit) { + var box = new HighlightBox(gui, {persistent: true, handles: true, classname: 'rectangles'}); + var _on = false; + var dragInfo; + + gui.addMode('rectangle_tool', turnOn, turnOff); + + gui.on('interaction_mode_change', function(e) { + if (e.mode === 'rectangles') { + gui.enterMode('rectangle_tool'); + } else if (gui.getMode() == 'rectangle_tool') { + gui.clearMode(); + } + }); + + hit.on('change', function(e) { + if (!_on) return; + // TODO: handle multiple hits (see gui-inspection-control2) + var id = e.id; + if (e.id > -1 && e.pinned) { + var target = hit.getHitTarget(); + var path = target.layer.shapes[e.id][0]; + var bbox = target.arcs.getSimpleShapeBounds(path).toArray(); + box.setDataCoords(bbox); + dragInfo = { + id: e.id, + target: target, + ids: [], + points: [] + }; + var iter = target.arcs.getShapeIter(path); + while (iter.hasNext()) { + dragInfo.points.push([iter.x, iter.y]); + dragInfo.ids.push(iter._arc.i); + } + gui.container.findChild('.map-layers').classed('dragging', true); + + } else if (dragInfo) { + // TODO: handle this event: add undo/redo states + gui.dispatchEvent('rectangle_dragend', dragInfo); + gui.container.findChild('.map-layers').classed('dragging', false); + reset(); + } else { + box.hide(); + } + + }); + + box.on('handle_drag', function(e) { + if (!_on || !dragInfo) return; + var coords = internal.bboxToCoords(box.getDataCoords()); + setRectangleCoords(dragInfo.target, dragInfo.ids, coords); + gui.dispatchEvent('map-needs-refresh'); + }); + + function turnOn() { + box.turnOn(); + _on = true; + } + + function turnOff() { + box.turnOff(); + if (gui.interaction.getMode() == 'rectangles') { + // mode change was not initiated by interactive menu -- turn off interactivity + gui.interaction.turnOff(); + } + _on = false; + reset(); + } + + function reset() { + box.hide(); + dragInfo = null; + } + } + // Create low-detail versions of large arc collections for faster rendering // at zoomed-out scales. function enhanceArcCollectionForDisplay(unfilteredArcs) { @@ -11372,6 +11483,7 @@ new InspectionControl2(gui, _hit); new SelectionTool(gui, _ext, _hit), new BoxTool(gui, _ext, _nav), + new RectangleControl(gui, _hit), initInteractiveEditing(gui, _ext, _hit); // initDrawing(gui, _ext, _mouse, _hit); _hit.on('change', updateOverlayLayer); diff --git a/mapshaper.js b/mapshaper.js index d30b33f0..1ea909a8 100644 --- a/mapshaper.js +++ b/mapshaper.js @@ -4122,6 +4122,30 @@ } }; + // TODO: make this stricter (could give false positive on some degenerate paths) + function pathIsRectangle(ids, arcs) { + var bbox = arcs.getSimpleShapeBounds(ids).toArray(); + var iter = arcs.getShapeIter(ids); + while (iter.hasNext()) { + if (iter.x != bbox[0] && iter.x != bbox[2] || + iter.y != bbox[1] && iter.y != bbox[3]) { + return false; + } + } + return true; + } + + function bboxToCoords(bbox) { + return [[bbox[0], bbox[1]], [bbox[0], bbox[3]], [bbox[2], bbox[3]], + [bbox[2], bbox[1]], [bbox[0], bbox[1]]]; + } + + var RectangleGeom = /*#__PURE__*/Object.freeze({ + __proto__: null, + pathIsRectangle: pathIsRectangle, + bboxToCoords: bboxToCoords + }); + // Insert a column of values into a (new or existing) data field function insertFieldValues(lyr, fieldName, values) { var size = getFeatureCount(lyr) || values.length, @@ -4162,6 +4186,15 @@ return lyr.geometry_type == 'point' && layerHasNonNullShapes(lyr); } + function layerOnlyHasRectangles(lyr, arcs) { + if (!layerHasPaths(lyr)) return false; + if (countMultiPartFeatures(lyr) > 0) return false; + return lyr.shapes.every(function(shp) { + if (!shp) return true; + return pathIsRectangle(shp[0], arcs); + }); + } + function layerHasNonNullShapes(lyr) { return utils.some(lyr.shapes || [], function(shp) { return !!shp; @@ -4405,6 +4438,7 @@ layerHasGeometry: layerHasGeometry, layerHasPaths: layerHasPaths, layerHasPoints: layerHasPoints, + layerOnlyHasRectangles: layerOnlyHasRectangles, layerHasNonNullShapes: layerHasNonNullShapes, deleteFeatureById: deleteFeatureById, transformPointsInLayer: transformPointsInLayer, @@ -5002,10 +5036,10 @@ // x, y: a point location in projected coordinates // Returns k, the ratio of coordinate distance to distance on the ground function getScaleFactorAtXY(x, y, crs) { - var dist = 1; + var dist = 1 / crs.to_meter; var lp = mproj.pj_inv_deg({x: x, y: y}, crs); var lp2 = mproj.pj_inv_deg({x: x + dist, y: y}, crs); - var k = dist / geom.greatCircleDistance(lp.lam, lp.phi, lp2.lam, lp2.phi); + var k = 1 / geom.greatCircleDistance(lp.lam, lp.phi, lp2.lam, lp2.phi); return k; } @@ -11058,7 +11092,7 @@ // File looks like an importable file type // name: filename or path function looksLikeImportableFile(name) { - return !!guessInputFileType(name); + return !!guessInputFileType(name) || isImportableAsBinary(name); } // File looks like a directly readable data file type @@ -19407,99 +19441,136 @@ } // TODO: generalize to other kinds of furniture as they are developed - function getScalebarPosition(d) { - var opts = { // defaults - valign: 'top', - halign: 'left', - voffs: 10, - hoffs: 10 + function getScalebarPosition(opts) { + var pos = opts.position || 'top-left'; + return { + valign: pos.includes('top') ? 'top' : 'bottom', + halign: pos.includes('left') ? 'left' : 'right' }; - if (+d.left > 0) { - opts.hoffs = +d.left; + } + + var styleOpts = { + a: { + bar_width: 3, + tic_length: 0 + }, + b: { + bar_width: 1, + tic_length: 5 } - if (+d.top > 0) { - opts.voffs = +d.top; + }; + + var defaultOpts = { + position: 'top-left', + label_position: 'top', + label_offset: 4, + font_size: 12, + margin: 12 + }; + + function getScalebarOpts(d) { + var style = d.style == 'b' || d.style == 'B' ? 'b' : 'a'; + return Object.assign({}, defaultOpts, styleOpts[style], d, {style: style}); + } + + // approximate pixel height of the scalebar + function getScalebarHeight(opts) { + return Math.round(opts.bar_width + opts.label_offset + + opts.tic_length + opts.font_size * 0.8); + } + + function renderAsSvg(length, text, opts) { + // label part + var xOff = opts.style == 'b' ? Math.round(opts.font_size / 4) : 0; + var alignLeft = opts.style == 'a' && opts.position.includes('left'); + var anchorX = alignLeft ? -xOff : length + xOff; + var anchorY = opts.bar_width + + opts.tic_length + opts.label_offset; + if (opts.label_position == 'top') { + anchorY = -opts.label_offset - opts.tic_length; } - if (+d.right > 0) { - opts.hoffs = +d.right; - opts.halign = 'right'; + var labelOpts = { + 'label-text': text, + 'font-size': opts.font_size, + 'text-anchor': alignLeft ? 'start': 'end', + 'dominant-baseline': opts.label_position == '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 labelPart = symbolRenderers.label(labelOpts, anchorX, anchorY); + var zeroOpts = Object.assign({}, labelOpts, {'label-text': '0', 'text-anchor': 'start'}); + var zeroLabel = symbolRenderers.label(zeroOpts, -xOff, anchorY); + + // bar part + var y = 0; + var y2 = opts.tic_length + opts.bar_width / 2; + var coords; + if (opts.label_position == "top") { + y2 = -y2; } - if (+d.bottom > 0) { - opts.voffs = +d.bottom; - opts.valign = 'bottom'; + if (opts.tic_length > 0) { + coords = [[0, y2], [0, y], [length, y], [length, y2]]; + } else { + coords = [[0, y], [length, y]]; } - return opts; + var barPart = importLineString(coords); + Object.assign(barPart.properties, { + stroke: 'black', + fill: 'none', + 'stroke-width': opts.bar_width, + 'stroke-linecap': 'butt', + 'stroke-linejoin': 'miter' + }); + var parts = opts.style == 'b' ? [zeroLabel, labelPart, barPart] : [labelPart, barPart]; + return { + tag: 'g', + children: parts + }; } function renderScalebar(d, frame) { - var pos = getScalebarPosition(d); + if (!frame.crs) { + message('Unable to render scalebar: unknown CRS.'); + return []; + } + if (frame.width > 0 === false) { + return []; + } + + var opts = getScalebarOpts(d); var metersPerPx = getMapFrameMetersPerPixel(frame); var frameWidthPx = frame.width; var unit = d.label ? parseScalebarUnits(d.label) : 'mile'; var number = d.label ? parseScalebarNumber(d.label) : null; var label = number && unit ? d.label : getAutoScalebarLabel(frameWidthPx, metersPerPx, unit); - var scalebarKm = parseScalebarLabelToKm(label); - var barHeight = 3; - var labelOffs = 4; - var fontSize = +d.font_size || 12; - 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' ? frameWidthPx - width - pos.hoffs : pos.hoffs; - var dy = pos.valign == 'bottom' ? frame.height - height - pos.voffs : pos.voffs; + var scalebarKm = parseScalebarLabelToKm(label); if (scalebarKm > 0 === false) { message('Unusable scalebar label:', label); return []; } - if (frameWidthPx > 0 === false) { - return []; - } - - if (!frame.crs) { - message('Unable to render scalebar: unknown CRS.'); - return []; + var width = Math.round(scalebarKm / metersPerPx * 1000); + if (width > 0 === false) { + stop("Null scalebar length"); } - if (labelPos == 'top') { - anchorY = -labelOffs; - dy += Math.round(labelOffs + fontSize * 0.8); + var pos = getScalebarPosition(opts); + var height = getScalebarHeight(opts); + var dx = pos.halign == 'right' ? frameWidthPx - width - opts.margin : opts.margin; + var dy = pos.valign == 'bottom' ? frame.height - height - opts.margin : opts.margin; + if (opts.label_position == 'top') { + dy += Math.round(opts.label_offset + opts.tic_length + opts.font_size * 0.8 + opts.bar_width / 2); + } else { + dy += Math.round(opts.bar_width / 2); } - 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 = symbolRenderers.label(labelOpts, anchorX, anchorY); - var g = { - tag: 'g', - children: [barObj, labelObj], - properties: { - transform: 'translate(' + dx + ' ' + dy + ')' - } + var g = renderAsSvg(width, label, opts); + g.properties = { + transform: 'translate(' + dx + ' ' + dy + ')' }; + return [g]; } @@ -25691,13 +25762,33 @@ ${svg} DEFAULT: true, describe: 'distance label, e.g. "35 miles"' }) - .option('top', {}) - .option('right', {}) - .option('bottom', {}) - .option('left', {}) - .option('font-size', {}) - // .option('font-family', {}) - .option('label-position', {}); // top or bottom + .option('style', { + describe: 'two options: a or b' + }) + .option('font-size', { + type: 'number' + }) + .option('tic-length', { + describe: 'length of tic marks (style b)', + type: 'number' + }) + .option('bar-width', { + describe: 'line width of bar', + type: 'number' + }) + .option('label-offset', { + type: 'number' + }) + .option('position', { + describe: 'e.g. bottom-right (default is top-left)' + }) + .option('label-position', { + describe: 'top or bottom' + }) + .option('margin', { + describe: 'offset in pixels from edge of map', + type: 'number' + }); parser.command('shape') .describe('create a polyline or polygon from coordinates') @@ -35673,11 +35764,285 @@ ${svg} }; } + var MAX_RULE_LEN = 50; + + cmd.info = function(targets, opts) { + var layers = expandCommandTargets(targets); + var arr = layers.map(function(o) { + return getLayerInfo(o.layer, o.dataset); + }); + + if (opts.save_to) { + var output = [{ + filename: opts.save_to + (opts.save_to.endsWith('.json') ? '' : '.json'), + content: JSON.stringify(arr, null, 2) + }]; + writeFiles(output, opts); + } + if (opts.to_layer) { + return { + info: {}, + layers: [{ + name: opts.name || 'info', + data: new DataTable(arr) + }] + }; + } + message(formatInfo(arr)); + }; + + cmd.printInfo = cmd.info; // old name + + function getLayerInfo(lyr, dataset) { + var n = getFeatureCount(lyr); + var o = { + layer_name: lyr.name, + geometry_type: lyr.geometry_type, + feature_count: n, + null_shape_count: 0, + null_data_count: lyr.data ? countNullRecords(lyr.data.getRecords()) : n + }; + if (lyr.shapes && lyr.shapes.length > 0) { + o.null_shape_count = countNullShapes(lyr.shapes); + o.bbox = getLayerBounds(lyr, dataset.arcs).toArray(); + o.proj4 = getProjInfo(dataset); + } + o.source_file = getLayerSourceFile(lyr, dataset) || null; + o.attribute_data = getAttributeTableInfo(lyr); + return o; + } + + // i: (optional) record index + function getAttributeTableInfo(lyr, i) { + if (!lyr.data || lyr.data.size() === 0 || lyr.data.getFields().length === 0) { + return null; + } + var fields = applyFieldOrder(lyr.data.getFields(), 'ascending'); + var valueName = i === undefined ? 'first_value' : 'value'; + return fields.map(function(fname) { + return { + field: fname, + [valueName]: lyr.data.getReadOnlyRecordAt(i || 0)[fname] + }; + }); + } + + function formatInfo(arr) { + var str = ''; + arr.forEach(function(info, i) { + var title = 'Layer: ' + (info.layer_name || '[unnamed layer]'); + var tableStr = formatAttributeTableInfo(info.attribute_data); + var tableWidth = measureLongestLine(tableStr); + var ruleLen = Math.min(Math.max(title.length, tableWidth), MAX_RULE_LEN); + str += '\n'; + str += utils.lpad('', ruleLen, '=') + '\n'; + str += title + '\n'; + str += utils.lpad('', ruleLen, '-') + '\n'; + str += formatLayerInfo(info); + str += tableStr; + }); + return str; + } + + function formatLayerInfo(data) { + var str = ''; + str += "Type: " + (data.geometry_type || "tabular data") + "\n"; + str += utils.format("Records: %,d\n",data.feature_count); + if (data.null_shape_count > 0) { + str += utils.format("Nulls: %'d", data.null_shape_count) + "\n"; + } + if (data.geometry_type && data.feature_count > data.null_shape_count) { + str += "Bounds: " + data.bbox.join(',') + "\n"; + str += "CRS: " + data.proj4 + "\n"; + } + str += "Source: " + (data.source_file || 'n/a') + "\n"; + return str; + } + + function formatAttributeTableInfo(arr) { + if (!arr) return "Attribute data: [none]\n"; + var header = "\nAttribute data\n"; + var valKey = 'first_value' in arr[0] ? 'first_value' : 'value'; + var vals = []; + var fields = []; + arr.forEach(function(o) { + fields.push(o.field); + vals.push(o[valKey]); + }); + var maxIntegralChars = vals.reduce(function(max, val) { + if (utils.isNumber(val)) { + max = Math.max(max, countIntegralChars(val)); + } + return max; + }, 0); + var col1Arr = ['Field'].concat(fields); + var col2Arr = vals.reduce(function(memo, val) { + memo.push(formatTableValue(val, maxIntegralChars)); + return memo; + }, [valKey == 'first_value' ? 'First value' : 'Value']); + var col1Chars = maxChars(col1Arr); + var col2Chars = maxChars(col2Arr); + var sepStr = (utils.rpad('', col1Chars + 2, '-') + '+' + + utils.rpad('', col2Chars + 2, '-')).substr(0, MAX_RULE_LEN); + var sepLine = sepStr + '\n'; + var table = ''; + col1Arr.forEach(function(col1, i) { + var w = stringDisplayWidth(col1); + table += ' ' + col1 + utils.rpad('', col1Chars - w, ' ') + ' | ' + + col2Arr[i] + '\n'; + if (i === 0) table += sepLine; // separator after first line + }); + return header + sepLine + table + sepLine; + } + + function measureLongestLine(str) { + return Math.max.apply(null, str.split('\n').map(function(line) {return stringDisplayWidth(line);})); + } + + function stringDisplayWidth(str) { + var w = 0; + for (var i = 0, n=str.length; i < n; i++) { + w += charDisplayWidth(str.charCodeAt(i)); + } + return w; + } + + // see https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c + // this is a simplified version, focusing on double-width CJK chars and ignoring nonprinting etc chars + function charDisplayWidth(c) { + if (c >= 0x1100 && + (c <= 0x115f || c == 0x2329 || c == 0x232a || + (c >= 0x2e80 && c <= 0xa4cf && c != 0x303f) || /* CJK ... Yi */ + (c >= 0xac00 && c <= 0xd7a3) || /* Hangul Syllables */ + (c >= 0xf900 && c <= 0xfaff) || /* CJK Compatibility Ideographs */ + (c >= 0xfe10 && c <= 0xfe19) || /* Vertical forms */ + (c >= 0xfe30 && c <= 0xfe6f) || /* CJK Compatibility Forms */ + (c >= 0xff00 && c <= 0xff60) || /* Fullwidth Forms */ + (c >= 0xffe0 && c <= 0xffe6) || + (c >= 0x20000 && c <= 0x2fffd) || + (c >= 0x30000 && c <= 0x3fffd))) return 2; + return 1; + } + + // TODO: consider polygons with zero area or other invalid geometries + function countNullShapes(shapes) { + var count = 0; + for (var i=0; i memo ? w : memo; + }, 0); + } + + function formatString(str) { + var replacements = { + '\n': '\\n', + '\r': '\\r', + '\t': '\\t' + }; + var cleanChar = function(c) { + // convert newlines and carriage returns + // TODO: better handling of non-printing chars + return c in replacements ? replacements[c] : ''; + }; + str = str.replace(/[\r\t\n]/g, cleanChar); + return "'" + str + "'"; + } + + function countIntegralChars(val) { + return utils.isNumber(val) ? (utils.formatNumber(val) + '.').indexOf('.') : 0; + } + + function formatTableValue(val, integralChars) { + var str; + if (utils.isNumber(val)) { + str = utils.lpad("", integralChars - countIntegralChars(val), ' ') + + utils.formatNumber(val); + } else if (utils.isString(val)) { + str = formatString(val); + } else if (utils.isDate(val)) { + str = utils.formatDateISO(val) + ' (Date)'; + } else if (utils.isObject(val)) { // if {} or [], display JSON + str = JSON.stringify(val); + } else { + str = String(val); + } + + if (typeof str != 'string') { + // e.g. JSON.stringify converts functions to undefined + str = '[' + (typeof val) + ']'; + } + + return str; + } + + var Info = /*#__PURE__*/Object.freeze({ + __proto__: null, + getLayerInfo: getLayerInfo, + getAttributeTableInfo: getAttributeTableInfo, + formatAttributeTableInfo: formatAttributeTableInfo, + formatTableValue: formatTableValue + }); + + // import { importGeoJSON } from '../geojson/geojson-import'; + + + function addTargetProxies(targets, ctx) { + if (targets && targets.length > 0) { + var proxies = expandCommandTargets(targets).reduce(function(memo, target) { + var proxy = getTargetProxy(target); + memo.push(proxy); + // index targets by layer name too + if (target.layer.name) { + memo[target.layer.name] = proxy; + } + return memo; + }, []); + Object.defineProperty(ctx, 'targets', {value: proxies}); + if (proxies.length == 1) { + Object.defineProperty(ctx, 'target', {value: proxies[0]}); + } + } + } + + function getTargetProxy(target) { + var proxy = getLayerInfo(target.layer, target.dataset); // layer_name, feature_count etc + proxy.layer = target.layer; + proxy.dataset = target.dataset; + addGetters(proxy, { + // export as an object, not a string or buffer + geojson: getGeoJSON + }); + + function getGeoJSON() { + var features = exportLayerAsGeoJSON(target.layer, target.dataset, {rfc7946: true}, true); + return { + type: 'FeatureCollection', + features: features + }; + } + + return proxy; + } + function compileIfCommandExpression(expr, catalog, opts) { return compileLayerExpression(expr, catalog, opts); } - function compileLayerExpression(expr, catalog, opts) { var targetId = opts.layer || opts.target || null; var targets = catalog.findCommandTargets(targetId); @@ -35693,6 +36058,10 @@ ${svg} } else { ctx = getNullLayerProxy(targets); } + + // add target/targets proxies, for consistency with the -run command + addTargetProxies(targets, ctx); + ctx.global = defs; // TODO: remove duplication with mapshaper.expressions.mjs var func = compileExpressionToFunction(expr, opts); @@ -38142,8 +38511,7 @@ ${svg} function convertBboxToGeoJSON(bbox, optsArg) { var opts = optsArg || {}; - var coords = [[bbox[0], bbox[1]], [bbox[0], bbox[3]], [bbox[2], bbox[3]], - [bbox[2], bbox[1]], [bbox[0], bbox[1]]]; + var coords = bboxToCoords(bbox); if (opts.interval > 0) { coords = densifyPathByInterval(coords, opts.interval); } @@ -39388,7 +39756,6 @@ ${svg} } function skipCommand(cmdName, job) { - // allow all control commands to run if (jobIsStopped(job)) return true; if (isControlFlowCommand(cmdName)) return false; return !inActiveBranch(job); @@ -39481,240 +39848,6 @@ ${svg} utils.extend(getStashedVar('defs'), obj); }; - var MAX_RULE_LEN = 50; - - cmd.info = function(targets, opts) { - var layers = expandCommandTargets(targets); - var arr = layers.map(function(o) { - return getLayerInfo(o.layer, o.dataset); - }); - - if (opts.save_to) { - var output = [{ - filename: opts.save_to + (opts.save_to.endsWith('.json') ? '' : '.json'), - content: JSON.stringify(arr, null, 2) - }]; - writeFiles(output, opts); - } - if (opts.to_layer) { - return { - info: {}, - layers: [{ - name: opts.name || 'info', - data: new DataTable(arr) - }] - }; - } - message(formatInfo(arr)); - }; - - cmd.printInfo = cmd.info; // old name - - function getLayerInfo(lyr, dataset) { - var n = getFeatureCount(lyr); - var o = { - layer_name: lyr.name, - geometry_type: lyr.geometry_type, - feature_count: n, - null_shape_count: 0, - null_data_count: lyr.data ? countNullRecords(lyr.data.getRecords()) : n - }; - if (lyr.shapes && lyr.shapes.length > 0) { - o.null_shape_count = countNullShapes(lyr.shapes); - o.bbox = getLayerBounds(lyr, dataset.arcs).toArray(); - o.proj4 = getProjInfo(dataset); - } - o.source_file = getLayerSourceFile(lyr, dataset) || null; - o.attribute_data = getAttributeTableInfo(lyr); - return o; - } - - // i: (optional) record index - function getAttributeTableInfo(lyr, i) { - if (!lyr.data || lyr.data.size() === 0 || lyr.data.getFields().length === 0) { - return null; - } - var fields = applyFieldOrder(lyr.data.getFields(), 'ascending'); - var valueName = i === undefined ? 'first_value' : 'value'; - return fields.map(function(fname) { - return { - field: fname, - [valueName]: lyr.data.getReadOnlyRecordAt(i || 0)[fname] - }; - }); - } - - function formatInfo(arr) { - var str = ''; - arr.forEach(function(info, i) { - var title = 'Layer: ' + (info.layer_name || '[unnamed layer]'); - var tableStr = formatAttributeTableInfo(info.attribute_data); - var tableWidth = measureLongestLine(tableStr); - var ruleLen = Math.min(Math.max(title.length, tableWidth), MAX_RULE_LEN); - str += '\n'; - str += utils.lpad('', ruleLen, '=') + '\n'; - str += title + '\n'; - str += utils.lpad('', ruleLen, '-') + '\n'; - str += formatLayerInfo(info); - str += tableStr; - }); - return str; - } - - function formatLayerInfo(data) { - var str = ''; - str += "Type: " + (data.geometry_type || "tabular data") + "\n"; - str += utils.format("Records: %,d\n",data.feature_count); - if (data.null_shape_count > 0) { - str += utils.format("Nulls: %'d", data.null_shape_count) + "\n"; - } - if (data.geometry_type && data.feature_count > data.null_shape_count) { - str += "Bounds: " + data.bbox.join(',') + "\n"; - str += "CRS: " + data.proj4 + "\n"; - } - str += "Source: " + (data.source_file || 'n/a') + "\n"; - return str; - } - - function formatAttributeTableInfo(arr) { - if (!arr) return "Attribute data: [none]\n"; - var header = "\nAttribute data\n"; - var valKey = 'first_value' in arr[0] ? 'first_value' : 'value'; - var vals = []; - var fields = []; - arr.forEach(function(o) { - fields.push(o.field); - vals.push(o[valKey]); - }); - var maxIntegralChars = vals.reduce(function(max, val) { - if (utils.isNumber(val)) { - max = Math.max(max, countIntegralChars(val)); - } - return max; - }, 0); - var col1Arr = ['Field'].concat(fields); - var col2Arr = vals.reduce(function(memo, val) { - memo.push(formatTableValue(val, maxIntegralChars)); - return memo; - }, [valKey == 'first_value' ? 'First value' : 'Value']); - var col1Chars = maxChars(col1Arr); - var col2Chars = maxChars(col2Arr); - var sepStr = (utils.rpad('', col1Chars + 2, '-') + '+' + - utils.rpad('', col2Chars + 2, '-')).substr(0, MAX_RULE_LEN); - var sepLine = sepStr + '\n'; - var table = ''; - col1Arr.forEach(function(col1, i) { - var w = stringDisplayWidth(col1); - table += ' ' + col1 + utils.rpad('', col1Chars - w, ' ') + ' | ' + - col2Arr[i] + '\n'; - if (i === 0) table += sepLine; // separator after first line - }); - return header + sepLine + table + sepLine; - } - - function measureLongestLine(str) { - return Math.max.apply(null, str.split('\n').map(function(line) {return stringDisplayWidth(line);})); - } - - function stringDisplayWidth(str) { - var w = 0; - for (var i = 0, n=str.length; i < n; i++) { - w += charDisplayWidth(str.charCodeAt(i)); - } - return w; - } - - // see https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c - // this is a simplified version, focusing on double-width CJK chars and ignoring nonprinting etc chars - function charDisplayWidth(c) { - if (c >= 0x1100 && - (c <= 0x115f || c == 0x2329 || c == 0x232a || - (c >= 0x2e80 && c <= 0xa4cf && c != 0x303f) || /* CJK ... Yi */ - (c >= 0xac00 && c <= 0xd7a3) || /* Hangul Syllables */ - (c >= 0xf900 && c <= 0xfaff) || /* CJK Compatibility Ideographs */ - (c >= 0xfe10 && c <= 0xfe19) || /* Vertical forms */ - (c >= 0xfe30 && c <= 0xfe6f) || /* CJK Compatibility Forms */ - (c >= 0xff00 && c <= 0xff60) || /* Fullwidth Forms */ - (c >= 0xffe0 && c <= 0xffe6) || - (c >= 0x20000 && c <= 0x2fffd) || - (c >= 0x30000 && c <= 0x3fffd))) return 2; - return 1; - } - - // TODO: consider polygons with zero area or other invalid geometries - function countNullShapes(shapes) { - var count = 0; - for (var i=0; i memo ? w : memo; - }, 0); - } - - function formatString(str) { - var replacements = { - '\n': '\\n', - '\r': '\\r', - '\t': '\\t' - }; - var cleanChar = function(c) { - // convert newlines and carriage returns - // TODO: better handling of non-printing chars - return c in replacements ? replacements[c] : ''; - }; - str = str.replace(/[\r\t\n]/g, cleanChar); - return "'" + str + "'"; - } - - function countIntegralChars(val) { - return utils.isNumber(val) ? (utils.formatNumber(val) + '.').indexOf('.') : 0; - } - - function formatTableValue(val, integralChars) { - var str; - if (utils.isNumber(val)) { - str = utils.lpad("", integralChars - countIntegralChars(val), ' ') + - utils.formatNumber(val); - } else if (utils.isString(val)) { - str = formatString(val); - } else if (utils.isDate(val)) { - str = utils.formatDateISO(val) + ' (Date)'; - } else if (utils.isObject(val)) { // if {} or [], display JSON - str = JSON.stringify(val); - } else { - str = String(val); - } - - if (typeof str != 'string') { - // e.g. JSON.stringify converts functions to undefined - str = '[' + (typeof val) + ']'; - } - - return str; - } - - var Info = /*#__PURE__*/Object.freeze({ - __proto__: null, - getLayerInfo: getLayerInfo, - getAttributeTableInfo: getAttributeTableInfo, - formatAttributeTableInfo: formatAttributeTableInfo, - formatTableValue: formatTableValue - }); - // TODO: make sure that the inlay shapes and data are not shared cmd.inlay = function(targetLayers, src, targetDataset, opts) { var mergedDataset = mergeLayersForOverlay(targetLayers, targetDataset, src, opts); @@ -42031,49 +42164,13 @@ ${svg} }, {}); } - // import { importGeoJSON } from '../geojson/geojson-import'; - - function getTargetProxy(target) { - var proxy = getLayerInfo(target.layer, target.dataset); // layer_name, feature_count etc - proxy.layer = target.layer; - proxy.dataset = target.dataset; - addGetters(proxy, { - // export as an object, not a string or buffer - geojson: getGeoJSON - }); - - function getGeoJSON() { - var features = exportLayerAsGeoJSON(target.layer, target.dataset, {rfc7946: true}, true); - return { - type: 'FeatureCollection', - features: features - }; - } - - return proxy; - } - // Support for evaluating expressions embedded in curly-brace templates // Returns: a string (e.g. a command string used by the -run command) async function evalTemplateExpression(expression, targets, ctx) { ctx = ctx || getBaseContext(); // TODO: throw an error if target is used when there are multiple targets - if (targets) { - var proxies = expandCommandTargets(targets).reduce(function(memo, target) { - var proxy = getTargetProxy(target); - memo.push(proxy); - // index targets by layer name too - if (target.layer.name) { - memo[target.layer.name] = proxy; - } - return memo; - }, []); - Object.defineProperty(ctx, 'targets', {value: proxies}); - if (proxies.length == 1) { - Object.defineProperty(ctx, 'target', {value: proxies[0]}); - } - } + addTargetProxies(targets, ctx); // Add global functions and data to the expression context // (e.g. functions imported via the -require command) var globals = getStashedVar('defs') || {}; @@ -44564,6 +44661,10 @@ ${svg} return done(null); } + if (name == 'comment') { + return done(null); + } + if (!job) job = new Job(); job.startCommand(command); @@ -44992,7 +45093,7 @@ ${svg} }); } - var version = "0.6.63"; + var version = "0.6.65"; // Parse command line args into commands and run them // Function takes an optional Node-style callback. A Promise is returned if no callback is given. @@ -45734,6 +45835,7 @@ ${svg} Projections, ProjectionParams, Rectangle, + RectangleGeom, Rounding, RunCommands, Scalebar, diff --git a/page.css b/page.css index 7da891c2..f47e23be 100644 --- a/page.css +++ b/page.css @@ -1186,6 +1186,10 @@ div.basemap-style-btn.active img { cursor: pointer; } +.map-layers.symbol-hit.dragging { + cursor: default; +} + .map-layers canvas { pointer-events: none; position: absolute; @@ -1411,6 +1415,11 @@ div.basemap-style-btn.active img { pointer-events: none; } +.zoom-box.rectangles { + border: none; + cursor: default; +} + .zoom-box .handle { position: absolute; z-index: 16;