From 6acdf8b841cc56995f6c67976d8d5f9998b37d03 Mon Sep 17 00:00:00 2001 From: Matthew Bloch Date: Sun, 19 Dec 2021 09:22:34 -0500 Subject: [PATCH] v0.5.80 --- CHANGELOG.md | 3 + package-lock.json | 2 +- package.json | 2 +- src/cli/mapshaper-options.js | 111 ++++++++------ src/cli/mapshaper-run-command.js | 3 +- src/commands/mapshaper-scalebar.js | 2 +- src/commands/mapshaper-shapes.js | 106 ------------- src/commands/mapshaper-symbols.js | 119 +++++++++++---- src/dataset/mapshaper-layer-utils.js | 3 +- src/expressions/mapshaper-feature-proxy.js | 7 + src/geom/mapshaper-basic-geom.js | 8 +- src/geom/mapshaper-geom-constants.js | 12 ++ src/geom/mapshaper-polygon-geom.js | 14 +- src/svg/mapshaper-svg-arrows.js | 108 -------------- ...ic-symbols.js => mapshaper-svg-symbols.js} | 0 src/svg/svg-common.js | 1 - src/svg/svg-path-utils.js | 28 ---- src/svg/svg-properties.js | 11 +- src/symbols/mapshaper-arrow-symbols.js | 139 ++++++++++++++++++ src/symbols/mapshaper-basic-symbols.js | 49 ++++++ src/symbols/mapshaper-symbol-utils.js | 73 +++++++++ test/svg-path-utils-test.js | 13 -- test/symbol-utils-test.js | 13 ++ test/symbols-test.js | 22 --- 24 files changed, 485 insertions(+), 364 deletions(-) delete mode 100644 src/commands/mapshaper-shapes.js create mode 100644 src/geom/mapshaper-geom-constants.js delete mode 100644 src/svg/mapshaper-svg-arrows.js rename src/svg/{mapshaper-basic-symbols.js => mapshaper-svg-symbols.js} (100%) create mode 100644 src/symbols/mapshaper-arrow-symbols.js create mode 100644 src/symbols/mapshaper-basic-symbols.js create mode 100644 src/symbols/mapshaper-symbol-utils.js delete mode 100644 test/svg-path-utils-test.js create mode 100644 test/symbol-utils-test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b7f186d8..9c0f40934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +v0.5.80 +* Added arrows, stars and polygons to undocumented -symbols command. + v0.5.79 * More permissive importing of some non-standard Shapefiles. diff --git a/package-lock.json b/package-lock.json index c7dd1d16c..776606a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "mapshaper", - "version": "0.5.79", + "version": "0.5.80", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 1c87ae8c0..3ba17edfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapshaper", - "version": "0.5.79", + "version": "0.5.80", "description": "A tool for editing vector datasets for mapping and GIS.", "keywords": [ "shapefile", diff --git a/src/cli/mapshaper-options.js b/src/cli/mapshaper-options.js index 2ea309770..74367bdde 100644 --- a/src/cli/mapshaper-options.js +++ b/src/cli/mapshaper-options.js @@ -1287,36 +1287,6 @@ export function getOptionParser() { }) .option('target', targetOpt); - parser.command('shapes') - .describe('convert points to polygons, circles or stars') - .option('type', { - describe: 'type of shape (e.g. star, polygon, circle)' - }) - .option('radius', { - describe: 'distance from center to farthest point on the shape', - type: 'distance' - }) - .option('units', { - describe: 'geographical units of radius parameter (e.g. km)' - }) - .option('sides', { - describe: 'number of sides; five-pointed stars have 10 sides', - type: 'number' - }) - .option('rotation', { - describe: 'rotation of the shape in degrees', - type: 'number' - }) - .option('orientation', { - describe: 'use orientation=b for a rotated orientation' - }) - .option('star-ratio', { - describe: 'ratio of major to minor radius of stars', - type: 'number' - }) - .option('name', nameOpt) - .option('target', targetOpt) - .option('no-replace', noReplaceOpt); parser.command('simplify') .validate(V.validateSimplifyOpts) @@ -1556,26 +1526,81 @@ export function getOptionParser() { .option('target', targetOpt); parser.command('symbols') - // .describe('generate a variety of SVG symbols') + // .describe('symbolize points as polygons, circles, stars or arrows') .option('type', { - describe: 'symbol type' + describe: 'symbol type (e.g. star, polygon, circle, arrow)' + }) + .option('scale', { + describe: 'scale symbols by a factor', + type: 'number' + }) + .option('pixel-scale', { + describe: 'symbol scale in meters-per-pixel (see polygons option)', + type: 'number', + }) + .option('polygons', { + describe: 'generate symbols as polygons instead of SVG objects', + type: 'flag' + }) + .option('radius', { + describe: 'distance from center to farthest point on the symbol', + type: 'distance' + }) + .option('sides', { + describe: 'sides of a polygon or star symbol', + type: 'number' + }) + .option('rotation', { + describe: 'rotation of symbol in degrees' + }) + .option('orientation', { + describe: 'use orientation=b for a rotated or flipped orientation' + }) + .option('length', { + // alias for arrow-length + }) + .option('star-ratio', { + describe: 'ratio of major to minor radius of star', + type: 'number' + }) + .option('arrow-length', { + describe: 'length of arrows in pixels (use with type=arrow)' + }) + .option('arrow-direction', { + describe: 'angle off of vertical (-90 = left-pointing arrow)' + }) + .option('arrow-head-angle', { + describe: 'angle of tip of arrow (default is 40 degrees)' + }) + .option('arrow-head-width', { + describe: 'size of arrow head from side to side' + }) + .option('arrow-stem-width', { + describe: 'width of stem at its widest point' + }) + .option('arrow-stem-taper', { + describe: 'factor for tapering the width of the stem' + }) + .option('arrow-stem-curve', { + describe: 'curvature in degrees (arrows are straight by default)' + }) + .option('arrow-min-stem', { + describe: 'min ratio of stem to total length (for small arrows)', + type: 'number' }) .option('stroke', {}) .option('stroke-width', {}) - .option('fill', {}) - .option('length', {}) - .option('rotation', {}) + .option('fill', { + describe: 'symbol fill color' + }) .option('effect', {}) - .option('arrow-head-angle', {}) - .option('arrow-stem-width', {}) - .option('arrow-head-width', {}) - .option('arrow-stem-curve', {}) - .option('arrow-stem-taper', {}) - .option('arrow-scaling', {}) - .option('where', whereOpt) - .option('target', targetOpt); + // .option('where', whereOpt) + .option('name', nameOpt) + .option('target', targetOpt) + .option('no-replace', noReplaceOpt); // .option('name', nameOpt); + parser.command('target') .describe('set active layer (or layers)') .option('target', { diff --git a/src/cli/mapshaper-run-command.js b/src/cli/mapshaper-run-command.js index a504e82f4..e99199cf6 100644 --- a/src/cli/mapshaper-run-command.js +++ b/src/cli/mapshaper-run-command.js @@ -66,7 +66,6 @@ import '../commands/mapshaper-rotate'; import '../commands/mapshaper-run'; import '../commands/mapshaper-scalebar'; import '../commands/mapshaper-shape'; -import '../commands/mapshaper-shapes'; import '../commands/mapshaper-simplify'; import '../commands/mapshaper-sort'; import '../commands/mapshaper-snap'; @@ -401,7 +400,7 @@ export function runCommand(command, catalog, cb) { applyCommandToEachLayer(cmd.svgStyle, targetLayers, targetDataset, opts); } else if (name == 'symbols') { - applyCommandToEachLayer(cmd.symbols, targetLayers, opts); + outputLayers = applyCommandToEachLayer(cmd.symbols, targetLayers, targetDataset, opts); } else if (name == 'subdivide') { outputLayers = applyCommandToEachLayer(cmd.subdivideLayer, targetLayers, arcs, opts.expression); diff --git a/src/commands/mapshaper-scalebar.js b/src/commands/mapshaper-scalebar.js index fc43bd512..4272ee2cf 100644 --- a/src/commands/mapshaper-scalebar.js +++ b/src/commands/mapshaper-scalebar.js @@ -5,7 +5,7 @@ import utils from '../utils/mapshaper-utils'; import { DataTable } from '../datatable/mapshaper-data-table'; import { stop } from '../utils/mapshaper-logging'; // import { symbolRenderers } from '../svg/svg-common'; -import { symbolRenderers } from '../svg/mapshaper-basic-symbols'; +import { symbolRenderers } from '../svg/mapshaper-svg-symbols'; cmd.scalebar = function(catalog, opts) { var frame = findFrameDataset(catalog); diff --git a/src/commands/mapshaper-shapes.js b/src/commands/mapshaper-shapes.js deleted file mode 100644 index ac3748e30..000000000 --- a/src/commands/mapshaper-shapes.js +++ /dev/null @@ -1,106 +0,0 @@ -import { stop } from '../utils/mapshaper-logging'; -import cmd from '../mapshaper-cmd'; -import { getDatasetCRS, getCRS, requireProjectedDataset } from '../crs/mapshaper-projections'; -import { requirePointLayer } from '../dataset/mapshaper-layer-utils'; -import { importGeoJSON } from '../geojson/geojson-import'; -import { getPointBufferPolygon, getPointBufferCoordinates } from '../buffer/mapshaper-point-buffer'; -import { getBufferDistanceFunction } from '../buffer/mapshaper-buffer-common'; -import { mergeOutputLayerIntoDataset } from '../dataset/mapshaper-dataset-utils'; -import { getGeodeticSegmentFunction } from '../geom/mapshaper-geodesic'; -import { getAffineTransform } from '../commands/mapshaper-affine'; - -cmd.shapes = function(lyr, dataset, opts) { - requireProjectedDataset(dataset); - requirePointLayer(lyr); - var type = opts.type || 'polygon'; - var sides = opts.sides || getDefaultSides(type); - var rotation = +opts.rotation || 0; - var distanceFn = getBufferDistanceFunction(lyr, dataset, opts); - var crs = getDatasetCRS(dataset); - var geod = getGeodeticSegmentFunction(crs); - var geometries = lyr.shapes.map(function(shape, i) { - var dist = distanceFn(i); - if (!dist || !shape) return null; - return getMultiPolygon(shape, geod, dist, sides, rotation, opts); - }); - var geojson = { - type: 'GeometryCollection', - geometries: geometries - }; - var dataset2 = importGeoJSON(geojson); - var lyr2 = mergeOutputLayerIntoDataset(lyr, dataset, dataset2, opts); - return [lyr2]; -}; - -function getDefaultSides(type) { - return { - star: 10, - circle: 72, - triangle: 3, - square: 4, - pentagon: 5, - hexagon: 6, - heptagon: 7, - octagon: 8, - nonagon: 9, - decagon: 10 - }[type] || 4; -} - -function getMultiPolygon(shape, geod, dist, sides, rotation, opts) { - var geom = { - type: 'MultiPolygon', - coordinates: [] - }; - var coords; - for (var i=0; i= 3 === false) { - stop(`Invalid number of sides (${sides})`); - } - var coords = [], - angle = 360 / sides, - b = isStar ? 1 : 0.5, - theta, even, len; - if (opts.orientation == 'b') { - b = 0; - } - for (var i=0; i 0 === false) return null; + coords = constructor(size, d, opts); + rotateCoords(coords, +d.rotation || 0); + if (!polygonMode) { + flipY(coords); + } + if (+opts.scale) { + scaleAndShiftCoords(coords, +opts.scale, [0, 0]); + } + if (polygonMode) { + scaleAndShiftCoords(coords, metersPerPx, shp[0]); + if (d.tfill) rec.fill = d.fill; + return createGeometry(coords); + } else { + rec['svg-symbol'] = makeSvgSymbol(coords, d); } }); + + var outputLyr, dataset2; + if (polygonMode) { + dataset2 = importGeometries(geometries, records); + outputLyr = mergeOutputLayerIntoDataset(inputLyr, dataset, dataset2, opts); + outputLyr.data = lyr.data; + } else { + outputLyr = lyr; + } + return [outputLyr]; }; +function importGeometries(geometries, records) { + var features = geometries.map(function(geom, i) { + var d = records[i]; + return { + type: 'Feature', + properties: records[i] || null, + geometry: geom + }; + }); + var geojson = { + type: 'FeatureCollection', + features: features + }; + return importGeoJSON(geojson); +} + +function createGeometry(coords) { + return { + type: 'Polygon', + coordinates: coords + }; +} + +function getMetersPerPixel(lyr, dataset) { + + // TODO: handle single point, no extent + var bounds = getLayerBounds(lyr); + return bounds.width() / 800; +} + // Returns an svg-symbol data object for one symbol -export function buildSymbol(properties) { - var type = properties.type; - var f = symbolBuilders[type]; - if (!type) { - stop('Missing required "type" parameter'); - } else if (!f) { - stop('Unknown symbol type:', type); - } - return f(properties); +export function makeSvgSymbol(coords, properties) { + roundCoordsForSVG(coords); + return { + type: 'polygon', + coordinates: coords, + fill: properties.fill || 'magenta' + }; } diff --git a/src/dataset/mapshaper-layer-utils.js b/src/dataset/mapshaper-layer-utils.js index 6fc1e6747..0ed56c94d 100644 --- a/src/dataset/mapshaper-layer-utils.js +++ b/src/dataset/mapshaper-layer-utils.js @@ -130,7 +130,7 @@ export function requirePointLayer(lyr, msg) { export function requireSinglePointLayer(lyr, msg) { requirePointLayer(lyr); if (countMultiPartFeatures(lyr) > 0) { - stop(msg || 'This command requires single points'); + stop(msg || 'This command requires single points; layer contains multi-point features.'); } } @@ -262,6 +262,7 @@ export function countArcsInLayers(layers, arcs) { return counts; } +// Returns a Bounds object export function getLayerBounds(lyr, arcs) { var bounds = null; if (lyr.geometry_type == 'point') { diff --git a/src/expressions/mapshaper-feature-proxy.js b/src/expressions/mapshaper-feature-proxy.js index 088320c26..3041c1495 100644 --- a/src/expressions/mapshaper-feature-proxy.js +++ b/src/expressions/mapshaper-feature-proxy.js @@ -5,6 +5,7 @@ import { layerHasPaths, layerHasPoints } from '../dataset/mapshaper-layer-utils' import { addLayerGetters } from '../expressions/mapshaper-layer-proxy'; import { addGetters } from '../expressions/mapshaper-expression-utils'; import { stop } from '../utils/mapshaper-logging'; +import { WGS84 } from '../geom/mapshaper-geom-constants'; import geom from '../geom/mapshaper-geom'; import utils from '../utils/mapshaper-utils'; @@ -89,6 +90,12 @@ export function initFeatureProxy(lyr, arcs, optsArg) { area: function() { return _isPlanar ? ctx.planarArea : geom.getSphericalShapeArea(_ids, arcs); }, + // area2: function() { + // return _isPlanar ? ctx.planarArea : geom.getSphericalShapeArea(_ids, arcs, WGS84.SEMIMINOR_RADIUS); + // }, + // area3: function() { + // return _isPlanar ? ctx.planarArea : geom.getSphericalShapeArea(_ids, arcs, WGS84.AUTHALIC_RADIUS); + // }, perimeter: function() { return geom.getShapePerimeter(_ids, arcs); }, diff --git a/src/geom/mapshaper-basic-geom.js b/src/geom/mapshaper-basic-geom.js index a2614213e..d0e464929 100644 --- a/src/geom/mapshaper-basic-geom.js +++ b/src/geom/mapshaper-basic-geom.js @@ -1,6 +1,8 @@ -// TODO: remove this constant, use actual data from dataset CRS -// also consider using ellipsoidal formulas when appropriate -export var R = 6378137; +import { WGS84 } from './mapshaper-geom-constants'; + +// TODO: remove this constant, use actual data from dataset CRS, +// also consider using ellipsoidal formulas where greater accuracy might be important. +export var R = WGS84.SEMIMAJOR_AXIS; export var D2R = Math.PI / 180; export var R2D = 180 / Math.PI; diff --git a/src/geom/mapshaper-geom-constants.js b/src/geom/mapshaper-geom-constants.js new file mode 100644 index 000000000..3b10637e5 --- /dev/null +++ b/src/geom/mapshaper-geom-constants.js @@ -0,0 +1,12 @@ + +var WGS84 = { + // https://en.wikipedia.org/wiki/Earth_radius + SEMIMAJOR_AXIS: 6378137, + SEMIMINOR_AXIS: 6356752.3142, + AUTHALIC_RADIUS: 6371007.2, + VOLUMETRIC_RADIUS: 6371000.8 +}; + +export { + WGS84 +}; diff --git a/src/geom/mapshaper-polygon-geom.js b/src/geom/mapshaper-polygon-geom.js index aeba727bb..43c600ede 100644 --- a/src/geom/mapshaper-polygon-geom.js +++ b/src/geom/mapshaper-polygon-geom.js @@ -2,6 +2,7 @@ import { error } from '../utils/mapshaper-logging'; import { forEachSegmentInPath } from '../paths/mapshaper-path-utils'; import { calcPathLen } from '../geom/mapshaper-path-geom'; +import { WGS84 } from '../geom/mapshaper-geom-constants'; // A compactness measure designed for testing electoral districts for gerrymandering. // Returns value in [0-1] range. 1 = perfect circle, 0 = collapsed polygon @@ -35,12 +36,12 @@ export function getPlanarShapeArea(shp, arcs) { }, 0); } -export function getSphericalShapeArea(shp, arcs) { +export function getSphericalShapeArea(shp, arcs, R) { if (arcs.isPlanar()) { error("[getSphericalShapeArea()] Function requires decimal degree coordinates"); } return (shp || []).reduce(function(area, ids) { - return area + getSphericalPathArea(ids, arcs); + return area + getSphericalPathArea(ids, arcs, R); }, 0); } @@ -157,16 +158,17 @@ export function getPathArea(ids, arcs) { return (arcs.isPlanar() ? getPlanarPathArea : getSphericalPathArea)(ids, arcs); } -export function getSphericalPathArea(ids, arcs) { +export function getSphericalPathArea(ids, arcs, R) { var iter = arcs.getShapeIter(ids); - return getSphericalPathArea2(iter); + return getSphericalPathArea2(iter, R); } -export function getSphericalPathArea2(iter) { +export function getSphericalPathArea2(iter, R) { var sum = 0, started = false, deg2rad = Math.PI / 180, x, y, xp, yp; + R = R || WGS84.SEMIMAJOR_AXIS; while (iter.hasNext()) { x = iter.x * deg2rad; y = Math.sin(iter.y * deg2rad); @@ -178,7 +180,7 @@ export function getSphericalPathArea2(iter) { xp = x; yp = y; } - return sum / 2 * 6378137 * 6378137; + return sum / 2 * R * R; } // Get path area from an array of [x, y] points diff --git a/src/svg/mapshaper-svg-arrows.js b/src/svg/mapshaper-svg-arrows.js deleted file mode 100644 index 170ba8385..000000000 --- a/src/svg/mapshaper-svg-arrows.js +++ /dev/null @@ -1,108 +0,0 @@ - -import { symbolBuilders } from '../svg/svg-common'; -import { addBezierArcControlPoints } from '../svg/svg-path-utils'; -import { getAffineTransform } from '../commands/mapshaper-affine'; - -symbolBuilders.arrow = function(d) { - var len = 'length' in d ? d.length : 10; - var filled = 'fill' in d; - return filled ? getFilledArrow(d, len) : getStickArrow(d, len); -}; - -function getStickArrow(d, len) { - return { - type: 'polyline', - coordinates: getStickArrowCoords(d, len), - stroke: d.stroke || 'magenta', - 'stroke-width': 'stroke-width' in d ? d['stroke-width'] : 1 - }; -} - -function getFilledArrow(d, totalLen) { - return { - type: 'polygon', - coordinates: getFilledArrowCoords(d, totalLen), - fill: d.fill || 'magenta' - }; -} - -function getScale(totalLen, headLen) { - var maxHeadPct = 0.60; - var headPct = headLen / totalLen; - if (headPct > maxHeadPct) { - return maxHeadPct / headPct; - } - return 1; -} - -function getStickArrowTip(totalLen, curve) { - // curve/2 intersects the arrowhead at 90deg (trigonometry) - var theta = Math.abs(curve/2) / 180 * Math.PI; - var dx = totalLen * Math.sin(theta) * (curve > 0 ? -1 : 1); - var dy = totalLen * Math.cos(theta); - return [dx, dy]; -} - -function addPoints(a, b) { - return [a[0] + b[0], a[1] + b[1]]; -} - -function getStickArrowCoords(d, totalLen) { - var headAngle = d['arrow-head-angle'] || 90; - var curve = d['arrow-stem-curve'] || 0; - var unscaledHeadWidth = d['arrow-head-width'] || 9; - var unscaledHeadLen = getHeadLength(unscaledHeadWidth, headAngle); - var scale = getScale(totalLen, unscaledHeadLen); // scale down small arrows - var headWidth = unscaledHeadWidth * scale; - var headLen = unscaledHeadLen * scale; - var tip = getStickArrowTip(totalLen, curve); - var stem = [[0, 0], tip.concat()]; - if (curve) { - addBezierArcControlPoints(stem, curve); - } - if (!headLen) return [stem]; - var head = [addPoints([-headWidth / 2, -headLen], tip), tip.concat(), addPoints([headWidth / 2, -headLen], tip)]; - - rotateSymbolCoords(stem, d.rotation); - rotateSymbolCoords(head, d.rotation); - return [stem, head]; -} - -function getHeadLength(headWidth, headAngle) { - var headRatio = 1 / Math.tan(Math.PI * headAngle / 180 / 2) / 2; // length-to-width head ratio - return headWidth * headRatio; -} - -function getFilledArrowCoords(d, totalLen) { - var headAngle = d['arrow-head-angle'] || 40, - unscaledStemWidth = d['arrow-stem-width'] || 2, - unscaledHeadWidth = d['arrow-head-width'] || unscaledStemWidth * 3, - unscaledHeadLen = getHeadLength(unscaledHeadWidth, headAngle), - scale = getScale(totalLen, unscaledHeadLen), // scale down small arrows - headWidth = unscaledHeadWidth * scale, - headLen = unscaledHeadLen * scale, - stemWidth = unscaledStemWidth * scale, - stemTaper = d['arrow-stem-taper'] || 0, - stemLen = totalLen - headLen; - - var headDx = headWidth / 2, - stemDx = stemWidth / 2, - baseDx = stemDx * (1 - stemTaper); - - var coords = [[baseDx, 0], [stemDx, stemLen], [headDx, stemLen], [0, stemLen + headLen], - [-headDx, stemLen], [-stemDx, stemLen], [-baseDx, 0], [baseDx, 0]]; - - rotateSymbolCoords(coords, d.rotation); - return [coords]; -} - -export function rotateSymbolCoords(coords, rotation) { - // TODO: consider avoiding re-instantiating function on every call - var f = getAffineTransform(rotation || 0, 1, [0, 0], [0, 0]); - coords.forEach(function(p) { - var p2 = f ? f(p[0], p[1]) : p; - p[0] = p2[0]; - p[1] = -p2[1]; // flip y-axis (to produce display coords) - }); -} - diff --git a/src/svg/mapshaper-basic-symbols.js b/src/svg/mapshaper-svg-symbols.js similarity index 100% rename from src/svg/mapshaper-basic-symbols.js rename to src/svg/mapshaper-svg-symbols.js diff --git a/src/svg/svg-common.js b/src/svg/svg-common.js index 3df198230..7542efba2 100644 --- a/src/svg/svg-common.js +++ b/src/svg/svg-common.js @@ -1,5 +1,4 @@ import { roundToTenths } from '../geom/mapshaper-rounding'; -export var symbolBuilders = {}; export var symbolRenderers = {}; export function getTransform(xy, scale) { diff --git a/src/svg/svg-path-utils.js b/src/svg/svg-path-utils.js index 69476032a..806c9433a 100644 --- a/src/svg/svg-path-utils.js +++ b/src/svg/svg-path-utils.js @@ -44,31 +44,3 @@ function stringifyBezierArc(coords) { stringifyCP(p2) + stringifyVertex(p2); } -export function findArcCenter(p1, p2, degrees) { - var p3 = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2], // midpoint betw. p1, p2 - tan = 1 / Math.tan(degrees / 180 * Math.PI / 2), - cp = getAffineTransform(90, tan, [0, 0], p3)(p2[0], p2[1]); - return cp; -} - -// export function addBezierArcControlPoints(p1, p2, degrees) { -export function addBezierArcControlPoints(points, degrees) { - // source: https://stackoverflow.com/questions/734076/how-to-best-approximate-a-geometrical-arc-with-a-bezier-curve - var p2 = points.pop(), - p1 = points.pop(), - cp = findArcCenter(p1, p2, degrees), - xc = cp[0], - yc = cp[1], - ax = p1[0] - xc, - ay = p1[1] - yc, - bx = p2[0] - xc, - by = p2[1] - yc, - q1 = ax * ax + ay * ay, - q2 = q1 + ax * bx + ay * by, - k2 = 4/3 * (Math.sqrt(2 * q1 * q2) - q2) / (ax * by - ay * bx); - - points.push(p1); - points.push([xc + ax - k2 * ay, yc + ay + k2 * ax, 'C']); - points.push([xc + bx + k2 * by, yc + by - k2 * bx, 'C']); - points.push(p2); -} diff --git a/src/svg/svg-properties.js b/src/svg/svg-properties.js index e763c0073..469258c87 100644 --- a/src/svg/svg-properties.js +++ b/src/svg/svg-properties.js @@ -36,11 +36,15 @@ var symbolPropertyTypes = utils.extend({ type: null, length: 'number', // e.g. arrow length rotation: 'number', + radius: 'number', + 'arrow-length': 'number', + 'arrow-direction': 'number', 'arrow-head-angle': 'number', 'arrow-head-width': 'number', 'arrow-stem-width': 'number', 'arrow-stem-curve': 'number', // degrees of arc 'arrow-stem-taper': 'number', + 'arrow-min-stem': 'number', 'arrow-scaling': 'number', effect: null // e.g. "fade" }, stylePropertyTypes); @@ -82,8 +86,8 @@ export function getSymbolDataAccessor(lyr, opts) { if (!isSupportedSvgSymbolProperty(svgName)) { return; } - var strVal = opts[optName].trim(); - functions[svgName] = getSymbolPropertyAccessor(strVal, svgName, lyr); + var val = opts[optName]; + functions[svgName] = getSymbolPropertyAccessor(val, svgName, lyr); properties.push(svgName); }); @@ -106,7 +110,8 @@ export function mightBeExpression(str, fields) { return /[(){}.+-/*?:&|=\[]/.test(str); } -export function getSymbolPropertyAccessor(strVal, svgName, lyr) { +export function getSymbolPropertyAccessor(val, svgName, lyr) { + var strVal = String(val).trim(); var typeHint = symbolPropertyTypes[svgName]; var fields = lyr.data ? lyr.data.getFields() : []; var literalVal = null; diff --git a/src/symbols/mapshaper-arrow-symbols.js b/src/symbols/mapshaper-arrow-symbols.js new file mode 100644 index 000000000..15081aec3 --- /dev/null +++ b/src/symbols/mapshaper-arrow-symbols.js @@ -0,0 +1,139 @@ + +import { addBezierArcControlPoints, rotateCoords } from './mapshaper-symbol-utils'; + +export function getStickArrowCoords(d, totalLen) { + var minStemRatio = getMinStemRatio(d); + var headAngle = d['arrow-head-angle'] || 90; + var curve = d['arrow-stem-curve'] || 0; + var unscaledHeadWidth = d['arrow-head-width'] || 9; + var unscaledHeadLen = getHeadLength(unscaledHeadWidth, headAngle); + var scale = getScale(totalLen, unscaledHeadLen, minStemRatio); + var headWidth = unscaledHeadWidth * scale; + var headLen = unscaledHeadLen * scale; + var tip = getStickArrowTip(totalLen, curve); + var stem = [[0, 0], tip.concat()]; + if (curve) { + addBezierArcControlPoints(stem, curve); + } + if (!headLen) return [stem]; + var head = [addPoints([-headWidth / 2, -headLen], tip), tip.concat(), addPoints([headWidth / 2, -headLen], tip)]; + + rotateCoords(stem, d.rotation); + rotateCoords(head, d.rotation); + return [stem, head]; +} + +function getMinStemRatio(d) { + return d['arrow-min-stem'] >= 0 ? d['arrow-min-stem'] : 0.4; +} + +export function getFilledArrowCoords(totalLen, d) { + var minStemRatio = getMinStemRatio(d), + headAngle = d['arrow-head-angle'] || 40, + direction = d.rotation || d['arrow-direction'] || 0, + unscaledStemWidth = d['arrow-stem-width'] || 2, + unscaledHeadWidth = d['arrow-head-width'] || unscaledStemWidth * 3, + unscaledHeadLen = getHeadLength(unscaledHeadWidth, headAngle), + scale = getScale(totalLen, unscaledHeadLen, minStemRatio), + headWidth = unscaledHeadWidth * scale, + headLen = unscaledHeadLen * scale, + stemWidth = unscaledStemWidth * scale, + stemTaper = d['arrow-stem-taper'] || 0, + stemCurve = d['arrow-stem-curve'] || 0, + stemLen = totalLen - headLen; + + var headDx = headWidth / 2, + stemDx = stemWidth / 2, + baseDx = stemDx * (1 - stemTaper); + + var coords; + + if (!stemCurve || Math.abs(stemCurve) > 90) { + coords = [[baseDx, 0], [stemDx, stemLen], [headDx, stemLen], [0, stemLen + headLen], + [-headDx, stemLen], [-stemDx, stemLen], [-baseDx, 0], [baseDx, 0]]; + } else { + if (direction > 0) stemCurve = -stemCurve; + coords = getCurvedArrowCoords(stemLen, headLen, stemCurve, stemDx, headDx, baseDx); + } + + rotateCoords(coords, direction); + return [coords]; +} + + +function getScale(totalLen, headLen, minStemRatio) { + var maxHeadPct = 1 - minStemRatio; + var headPct = headLen / totalLen; + if (headPct > maxHeadPct) { + return maxHeadPct / headPct; + } + return 1; +} + +function getStickArrowTip(totalLen, curve) { + // curve/2 intersects the arrowhead at 90deg (trigonometry) + var theta = Math.abs(curve/2) / 180 * Math.PI; + var dx = totalLen * Math.sin(theta) * (curve > 0 ? -1 : 1); + var dy = totalLen * Math.cos(theta); + return [dx, dy]; +} + +function addPoints(a, b) { + return [a[0] + b[0], a[1] + b[1]]; +} + + +function getHeadLength(headWidth, headAngle) { + var headRatio = 1 / Math.tan(Math.PI * headAngle / 180 / 2) / 2; // length-to-width head ratio + return headWidth * headRatio; +} + +function getCurvedArrowCoords(stemLen, headLen, curvature, stemDx, headDx, baseDx) { + // coordinates go counter clockwise, starting from the leftmost head coordinate + var theta = Math.abs(curvature) / 180 * Math.PI; + var sign = curvature > 0 ? 1 : -1; + var dx = stemLen * Math.sin(theta / 2) * sign; + var dy = stemLen * Math.cos(theta / 2); + var head = [[stemDx + dx, dy], [headDx + dx, dy], + [dx, headLen + dy], [-headDx + dx, dy], [-stemDx + dx, dy]]; + var ax = baseDx * Math.cos(theta); // rotate arrow base + var ay = baseDx * Math.sin(theta) * -sign; + var leftStem = getCurvedStemCoords(-ax, -ay, -stemDx + dx, dy, theta); + var rightStem = getCurvedStemCoords(ax, ay, stemDx + dx, dy, theta); + // if (stemTaper == 1) leftStem.pop(); + var stem = leftStem.concat(rightStem.reverse()); + stem.pop(); + return stem.concat(head); +} + +// ax, ay: point on the base +// bx, by: point on the stem +function getCurvedStemCoords(ax, ay, bx, by, theta0) { + var dx = bx - ax, + dy = by - ay, + dy1 = (dy * dy - dx * dx) / (2 * dy), + dy2 = dy - dy1, + dx2 = Math.sqrt(dx * dx + dy * dy) / 2, + theta = Math.PI - Math.asin(dx2 / dy2) * 2, + degrees = theta * 180 / Math.PI, + radius = dy2 / Math.tan(theta / 2), + leftBend = bx > ax, + sign = leftBend ? 1 : -1, + points = Math.round(degrees / 5) + 2, + // points = theta > 2 && 7 || theta > 1 && 6 || 5, + increment = theta / (points + 1); + + var coords = [[bx, by]]; + for (var i=1; i<= points; i++) { + var phi = i * increment / 2; + var sinPhi = Math.sin(phi); + var cosPhi = Math.cos(phi); + var c = sinPhi * radius * 2; + var a = sinPhi * c; + var b = cosPhi * c; + coords.push([bx - a * sign, by - b]); + } + coords.push([ax, ay]); + return coords; +} + diff --git a/src/symbols/mapshaper-basic-symbols.js b/src/symbols/mapshaper-basic-symbols.js new file mode 100644 index 000000000..5ba07c16a --- /dev/null +++ b/src/symbols/mapshaper-basic-symbols.js @@ -0,0 +1,49 @@ +import { getPlanarSegmentEndpoint } from '../geom/mapshaper-geodesic'; +import { stop } from '../utils/mapshaper-logging'; + +// sides: e.g. 5-pointed star has 10 sides +// radius: distance from center to point +// +export function getPolygonCoords(radius, opts) { + var type = opts.type; + var sides = +opts.sides || getDefaultSides(type); + var isStar = type == 'star'; + if (isStar && (sides < 6 || sides % 2 !== 0)) { + stop(`Invalid number of sides for a star (${sides})`); + } else if (sides >= 3 === false) { + stop(`Invalid number of sides (${sides})`); + } + var coords = [], + angle = 360 / sides, + b = isStar ? 1 : 0.5, + theta, even, len; + if (opts.orientation == 'b') { + b = 0; + } + for (var i=0; i