From 0b9cc967da9de704faa04dafdcf56b3fdfab2bd1 Mon Sep 17 00:00:00 2001 From: Matthew Bloch Date: Wed, 21 Feb 2024 01:54:58 -0500 Subject: [PATCH] Added interactive resizing for rectangles --- CHANGELOG.md | 3 + REFERENCE.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/commands/mapshaper-rectangle.mjs | 4 +- src/dataset/mapshaper-layer-utils.mjs | 10 +++ src/geom/mapshaper-rectangle-geom.mjs | 18 ++++++ src/gui/gui-display-utils.mjs | 19 ++++++ src/gui/gui-highlight-box.mjs | 13 +++- src/gui/gui-hit-control.mjs | 3 +- src/gui/gui-interaction-mode-control.mjs | 9 ++- src/gui/gui-map-style.mjs | 5 +- src/gui/gui-map.mjs | 2 + src/gui/gui-rectangle-control.mjs | 79 ++++++++++++++++++++++++ src/mapshaper-internal.mjs | 2 + www/page.css | 9 +++ 16 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 src/geom/mapshaper-rectangle-geom.mjs create mode 100644 src/gui/gui-rectangle-control.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 462ba141..b7c1b1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +v0.6.65 +* Added "drag-to-resize" editing mode for resizing rectangles interactively. + v0.6.64 * Added more options for the -scalebar command, including two styles, a and b. diff --git a/REFERENCE.md b/REFERENCE.md index 9b738276..3217ba23 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1150,7 +1150,7 @@ The length of the scale bar reflects the scale in the center of the map's rectan `label-position=` Position of labels relative to the bar (`top` or `bottom`). -`position=` Position of the scalebar in on the map (default is `top-left`). +`position=` Position of the scalebar on the map (default is `top-left`). `margin=` Offset in pixels from edge of map. diff --git a/package-lock.json b/package-lock.json index 95cad514..dc7fbee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mapshaper", - "version": "0.6.64", + "version": "0.6.65", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mapshaper", - "version": "0.6.64", + "version": "0.6.65", "license": "MPL-2.0", "dependencies": { "@placemarkio/tokml": "^0.3.3", diff --git a/package.json b/package.json index debcee12..e062e410 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapshaper", - "version": "0.6.64", + "version": "0.6.65", "description": "A tool for editing vector datasets for mapping and GIS.", "keywords": [ "shapefile", diff --git a/src/commands/mapshaper-rectangle.mjs b/src/commands/mapshaper-rectangle.mjs index c592bdb8..026f8d33 100644 --- a/src/commands/mapshaper-rectangle.mjs +++ b/src/commands/mapshaper-rectangle.mjs @@ -10,6 +10,7 @@ import { stop } from '../utils/mapshaper-logging'; import { probablyDecimalDegreeBounds, clampToWorldBounds } from '../geom/mapshaper-latlon'; import { Bounds } from '../geom/mapshaper-bounds'; import { densifyPathByInterval } from '../crs/mapshaper-densify'; +import { bboxToCoords } from '../geom/mapshaper-rectangle-geom'; // Create rectangles around each feature in a layer cmd.rectangles = function(targetLyr, targetDataset, opts) { @@ -126,8 +127,7 @@ function applyBoundsOffset(offsetOpt, bounds, crs) { export 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); } diff --git a/src/dataset/mapshaper-layer-utils.mjs b/src/dataset/mapshaper-layer-utils.mjs index 45ac6b6b..b6ac5257 100644 --- a/src/dataset/mapshaper-layer-utils.mjs +++ b/src/dataset/mapshaper-layer-utils.mjs @@ -8,6 +8,7 @@ import { DataTable } from '../datatable/mapshaper-data-table'; import { getFirstNonEmptyRecord } from '../datatable/mapshaper-data-utils'; import utils from '../utils/mapshaper-utils'; import { absArcId } from '../paths/mapshaper-arc-utils'; +import { pathIsRectangle } from '../geom/mapshaper-rectangle-geom'; // Insert a column of values into a (new or existing) data field export function insertFieldValues(lyr, fieldName, values) { @@ -49,6 +50,15 @@ export function layerHasPoints(lyr) { return lyr.geometry_type == 'point' && layerHasNonNullShapes(lyr); } +export 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); + }); +} + export function layerHasNonNullShapes(lyr) { return utils.some(lyr.shapes || [], function(shp) { return !!shp; diff --git a/src/geom/mapshaper-rectangle-geom.mjs b/src/geom/mapshaper-rectangle-geom.mjs new file mode 100644 index 00000000..ac25e9e4 --- /dev/null +++ b/src/geom/mapshaper-rectangle-geom.mjs @@ -0,0 +1,18 @@ + +// TODO: make this stricter (could give false positive on some degenerate paths) +export 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; +} + +export function bboxToCoords(bbox) { + return [[bbox[0], bbox[1]], [bbox[0], bbox[3]], [bbox[2], bbox[3]], + [bbox[2], bbox[1]], [bbox[0], bbox[1]]]; +} diff --git a/src/gui/gui-display-utils.mjs b/src/gui/gui-display-utils.mjs index 4f7af506..e519208f 100644 --- a/src/gui/gui-display-utils.mjs +++ b/src/gui/gui-display-utils.mjs @@ -97,6 +97,25 @@ export function updateVertexCoords(lyr, ids) { internal.snapVerticesToPoint(ids, lyr.invertPoint(p[0], p[1]), lyr.source.dataset.arcs, true); } +export 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); diff --git a/src/gui/gui-highlight-box.mjs b/src/gui/gui-highlight-box.mjs index ee4492b5..8d6e38cd 100644 --- a/src/gui/gui-highlight-box.mjs +++ b/src/gui/gui-highlight-box.mjs @@ -14,6 +14,10 @@ export function HighlightBox(gui, optsArg) { _on = false, handles; + if (opts.classname) { + el.addClass(opts.classname); + } + el.hide(); gui.on('map_rendered', function() { @@ -84,6 +88,11 @@ export function HighlightBox(gui, optsArg) { }); } + box.setDataCoords = function(bbox) { + boxCoords = bbox; + redraw(); + }; + box.getDataCoords = function() { if (!boxCoords) return null; var dataBox = getBBoxCoords(gui.map.getActiveLayer(), boxCoords); @@ -110,8 +119,8 @@ export function HighlightBox(gui, optsArg) { 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(); diff --git a/src/gui/gui-hit-control.mjs b/src/gui/gui-hit-control.mjs index 38112dfb..a9dd7ae7 100644 --- a/src/gui/gui-hit-control.mjs +++ b/src/gui/gui-hit-control.mjs @@ -88,7 +88,8 @@ export function HitControl(gui, ext, mouse) { 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;}; diff --git a/src/gui/gui-interaction-mode-control.mjs b/src/gui/gui-interaction-mode-control.mjs index 3573ea7c..8ee5809b 100644 --- a/src/gui/gui-interaction-mode-control.mjs +++ b/src/gui/gui-interaction-mode-control.mjs @@ -21,6 +21,7 @@ export function InteractionMode(gui) { 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'], @@ -44,6 +45,7 @@ export function InteractionMode(gui) { vertices: 'edit vertices', selection: 'select features', 'add-points': 'add points', + rectangles: 'drag-to-resize', off: 'turn off' }; var btn, menu; @@ -98,11 +100,11 @@ export function InteractionMode(gui) { }; 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; @@ -144,7 +146,8 @@ export function InteractionMode(gui) { 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; } diff --git a/src/gui/gui-map-style.mjs b/src/gui/gui-map-style.mjs index 99c629dd..d845d93c 100644 --- a/src/gui/gui-map-style.mjs +++ b/src/gui/gui-map-style.mjs @@ -228,7 +228,10 @@ function getSelectedFeatureStyle(lyr, o) { 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) { diff --git a/src/gui/gui-map.mjs b/src/gui/gui-map.mjs index 65dea267..57cf0faa 100644 --- a/src/gui/gui-map.mjs +++ b/src/gui/gui-map.mjs @@ -11,6 +11,7 @@ import * as MapStyle from './gui-map-style'; import { MapExtent } from './gui-map-extent'; import { LayerStack } from './gui-layer-stack'; import { BoxTool } from './gui-box-tool'; +import { RectangleControl } from './gui-rectangle-control'; import { projectMapExtent, getMapboxBounds } from './gui-dynamic-crs'; import { getDisplayLayer, projectDisplayLayer } from './gui-display-layer'; import { utils, internal, Bounds } from './gui-core'; @@ -223,6 +224,7 @@ export function MshpMap(gui) { 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/src/gui/gui-rectangle-control.mjs b/src/gui/gui-rectangle-control.mjs new file mode 100644 index 00000000..339c2124 --- /dev/null +++ b/src/gui/gui-rectangle-control.mjs @@ -0,0 +1,79 @@ +import { HighlightBox } from './gui-highlight-box'; +import { internal } from './gui-core'; +import { setRectangleCoords } from './gui-display-utils'; + +export 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; + } +} diff --git a/src/mapshaper-internal.mjs b/src/mapshaper-internal.mjs index f4b061d5..cb2e8bf2 100644 --- a/src/mapshaper-internal.mjs +++ b/src/mapshaper-internal.mjs @@ -157,6 +157,7 @@ import * as Proj from './commands/mapshaper-proj'; import * as Projections from './crs/mapshaper-projections'; import * as ProjectionParams from './crs/mapshaper-projection-params'; import * as Rectangle from './commands/mapshaper-rectangle'; +import * as RectangleGeom from './geom/mapshaper-rectangle-geom'; import * as Rounding from './geom/mapshaper-rounding'; import * as RunCommands from './cli/mapshaper-run-commands'; import * as Scalebar from './commands/mapshaper-scalebar'; @@ -275,6 +276,7 @@ Object.assign(internal, Projections, ProjectionParams, Rectangle, + RectangleGeom, Rounding, RunCommands, Scalebar, diff --git a/www/page.css b/www/page.css index 7da891c2..f47e23be 100644 --- a/www/page.css +++ b/www/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;