Skip to content

Commit

Permalink
Added interactive resizing for rectangles
Browse files Browse the repository at this point in the history
  • Loading branch information
mbloch committed Feb 21, 2024
1 parent 73aa424 commit 0b9cc96
Show file tree
Hide file tree
Showing 16 changed files with 171 additions and 13 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/commands/mapshaper-rectangle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions src/dataset/mapshaper-layer-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/geom/mapshaper-rectangle-geom.mjs
Original file line number Diff line number Diff line change
@@ -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]]];
}
19 changes: 19 additions & 0 deletions src/gui/gui-display-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 11 additions & 2 deletions src/gui/gui-highlight-box.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion src/gui/gui-hit-control.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;};
Expand Down
9 changes: 6 additions & 3 deletions src/gui/gui-interaction-mode-control.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion src/gui/gui-map-style.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/gui/gui-map.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
79 changes: 79 additions & 0 deletions src/gui/gui-rectangle-control.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions src/mapshaper-internal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -275,6 +276,7 @@ Object.assign(internal,
Projections,
ProjectionParams,
Rectangle,
RectangleGeom,
Rounding,
RunCommands,
Scalebar,
Expand Down
9 changes: 9 additions & 0 deletions www/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 0b9cc96

Please sign in to comment.