mapshaper 0.5.112 → 0.5.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mapshaper.js CHANGED
@@ -1,6 +1,6 @@
1
1
  (function () {
2
2
 
3
- var VERSION = "0.5.112";
3
+ var VERSION = "0.5.115";
4
4
 
5
5
 
6
6
  var utils = /*#__PURE__*/Object.freeze({
@@ -13607,6 +13607,224 @@
13607
13607
  importFurniture: importFurniture
13608
13608
  });
13609
13609
 
13610
+ /*
13611
+ {
13612
+ width: size[0],
13613
+ height: size[1],
13614
+ bbox: bounds.toArray(),
13615
+ type: 'frame'
13616
+ }
13617
+ */
13618
+ function getFrameData(dataset, exportOpts) {
13619
+ var frameLyr = findFrameLayerInDataset(dataset);
13620
+ var frameData;
13621
+ if (frameLyr) {
13622
+ frameData = Object.assign({}, getFrameLayerData(frameLyr));
13623
+ } else {
13624
+ frameData = calcFrameData(dataset, exportOpts);
13625
+ }
13626
+ frameData.crs = getDatasetCRS(dataset);
13627
+ return frameData;
13628
+ }
13629
+
13630
+ function calcFrameData(dataset, opts) {
13631
+ var bounds = getDatasetBounds(dataset);
13632
+ var bounds2 = calcOutputSizeInPixels(bounds, opts);
13633
+ return {
13634
+ bbox: bounds.toArray(),
13635
+ width: Math.round(bounds2.width()),
13636
+ height: Math.round(bounds2.height()) || 1,
13637
+ type: 'frame'
13638
+ };
13639
+ }
13640
+
13641
+ function getFrameLayerData(lyr) {
13642
+ return lyr.data && lyr.data.getReadOnlyRecordAt(0);
13643
+ }
13644
+
13645
+ // Used by mapshaper-frame and mapshaper-pixel-transform. TODO: refactor
13646
+ function getFrameSize(bounds, opts) {
13647
+ var aspectRatio = bounds.width() / bounds.height();
13648
+ var height, width;
13649
+ if (opts.pixels) {
13650
+ width = Math.sqrt(+opts.pixels * aspectRatio);
13651
+ } else {
13652
+ width = +opts.width;
13653
+ }
13654
+ height = width / aspectRatio;
13655
+ return [Math.round(width), Math.round(height)];
13656
+ }
13657
+
13658
+
13659
+ // @lyr dataset layer
13660
+ function isFrameLayer(lyr) {
13661
+ return getFurnitureLayerType(lyr) == 'frame';
13662
+ }
13663
+
13664
+ function findFrameLayerInDataset(dataset) {
13665
+ return utils.find(dataset.layers, function(lyr) {
13666
+ return isFrameLayer(lyr);
13667
+ });
13668
+ }
13669
+
13670
+ function findFrameDataset(catalog) {
13671
+ var target = utils.find(catalog.getLayers(), function(o) {
13672
+ return isFrameLayer(o.layer);
13673
+ });
13674
+ return target ? target.dataset : null;
13675
+ }
13676
+
13677
+ function findFrameLayer(catalog) {
13678
+ var target = utils.find(catalog.getLayers(), function(o) {
13679
+ return isFrameLayer(o.layer);
13680
+ });
13681
+ return target && target.layer || null;
13682
+ }
13683
+
13684
+ function getFrameLayerBounds(lyr) {
13685
+ return new Bounds(getFrameLayerData(lyr).bbox);
13686
+ }
13687
+
13688
+ // @data frame data, including crs property if available
13689
+ // Returns a single value: the ratio or
13690
+ function getMapFrameMetersPerPixel(data) {
13691
+ var bounds = new Bounds(data.bbox);
13692
+ var k, toMeters, metersPerPixel;
13693
+ if (data.crs) {
13694
+ // TODO: handle CRS without inverse projections
13695
+ // scale factor is the ratio of coordinate distance to true distance at a point
13696
+ k = getScaleFactorAtXY(bounds.centerX(), bounds.centerY(), data.crs);
13697
+ toMeters = data.crs.to_meter;
13698
+ } else {
13699
+ // Assuming coordinates are meters and k is 1 (not safe)
13700
+ // A warning should be displayed when relevant furniture element is created
13701
+ k = 1;
13702
+ toMeters = 1;
13703
+ }
13704
+ metersPerPixel = bounds.width() / k * toMeters / data.width;
13705
+ return metersPerPixel;
13706
+ }
13707
+
13708
+
13709
+ // bounds: Bounds object containing bounds of content in geographic coordinates
13710
+ // returns Bounds object containing bounds of pixel output
13711
+ // side effect: bounds param is modified to match the output frame
13712
+ function calcOutputSizeInPixels(bounds, opts) {
13713
+ var padX = 0,
13714
+ padY = 0,
13715
+ offX = 0,
13716
+ offY = 0,
13717
+ width = bounds.width(),
13718
+ height = bounds.height(),
13719
+ margins = parseMarginOption(opts.margin),
13720
+ marginX = margins[0] + margins[2],
13721
+ marginY = margins[1] + margins[3],
13722
+ // TODO: add option to tweak alignment of content when both width and height are given
13723
+ wx = 0.5, // how padding is distributed horizontally (0: left aligned, 0.5: centered, 1: right aligned)
13724
+ wy = 0.5, // vertical padding distribution
13725
+ widthPx, heightPx, size, kx, ky;
13726
+
13727
+ if (opts.fit_bbox) {
13728
+ // scale + shift content to fit within a bbox
13729
+ offX = opts.fit_bbox[0];
13730
+ offY = opts.fit_bbox[1];
13731
+ widthPx = opts.fit_bbox[2] - offX;
13732
+ heightPx = opts.fit_bbox[3] - offY;
13733
+ if (width / height > widthPx / heightPx) {
13734
+ // data is wider than fit box...
13735
+ // scale the data to fit widthwise
13736
+ heightPx = 0;
13737
+ } else {
13738
+ widthPx = 0; // fit the data to the height
13739
+ }
13740
+ marginX = marginY = 0; // TODO: support margins
13741
+
13742
+ } else if (opts.svg_scale > 0) {
13743
+ // alternative to using a fixed width (e.g. when generating multiple files
13744
+ // at a consistent geographic scale)
13745
+ widthPx = width / opts.svg_scale + marginX;
13746
+ heightPx = 0;
13747
+ } else if (+opts.pixels) {
13748
+ size = getFrameSize(bounds, opts);
13749
+ widthPx = size[0];
13750
+ heightPx = size[1];
13751
+ } else {
13752
+ heightPx = opts.height || 0;
13753
+ widthPx = opts.width || (heightPx > 0 ? 0 : 800); // 800 is default width
13754
+ }
13755
+
13756
+ if (heightPx > 0) {
13757
+ // vertical meters per pixel to fit height param
13758
+ ky = (height || width || 1) / (heightPx - marginY);
13759
+ }
13760
+ if (widthPx > 0) {
13761
+ // horizontal meters per pixel to fit width param
13762
+ kx = (width || height || 1) / (widthPx - marginX);
13763
+ }
13764
+
13765
+ if (!widthPx) { // heightPx and ky are defined, set width to match
13766
+ kx = ky;
13767
+ widthPx = width > 0 ? marginX + width / kx : heightPx; // export square graphic if content has 0 width (reconsider this?)
13768
+ } else if (!heightPx) { // widthPx and kx are set, set height to match
13769
+ ky = kx;
13770
+ heightPx = height > 0 ? marginY + height / ky : widthPx;
13771
+ // limit height if max_height is defined
13772
+ if (opts.max_height > 0 && heightPx > opts.max_height) {
13773
+ ky = kx * heightPx / opts.max_height;
13774
+ heightPx = opts.max_height;
13775
+ }
13776
+ }
13777
+
13778
+ if (kx > ky) { // content is wide -- need to pad vertically
13779
+ ky = kx;
13780
+ padY = ky * (heightPx - marginY) - height;
13781
+ } else if (ky > kx) { // content is tall -- need to pad horizontally
13782
+ kx = ky;
13783
+ padX = kx * (widthPx - marginX) - width;
13784
+ }
13785
+
13786
+ bounds.padBounds(
13787
+ margins[0] * kx + padX * wx,
13788
+ margins[1] * ky + padY * wy,
13789
+ margins[2] * kx + padX * (1 - wx),
13790
+ margins[3] * ky + padY * (1 - wy));
13791
+
13792
+ if (!(widthPx > 0 && heightPx > 0)) {
13793
+ error("Missing valid height and width parameters");
13794
+ }
13795
+ if (!(kx === ky && kx > 0)) {
13796
+ error("Missing valid margin parameters");
13797
+ }
13798
+
13799
+ return new Bounds(offX, offY, widthPx + offX, heightPx + offY);
13800
+ }
13801
+
13802
+ function parseMarginOption(opt) {
13803
+ var str = utils.isNumber(opt) ? String(opt) : opt || '';
13804
+ var margins = str.trim().split(/[, ] */);
13805
+ if (margins.length == 1) margins.push(margins[0]);
13806
+ if (margins.length == 2) margins.push(margins[0], margins[1]);
13807
+ if (margins.length == 3) margins.push(margins[2]);
13808
+ return margins.map(function(str) {
13809
+ var px = parseFloat(str);
13810
+ return isNaN(px) ? 1 : px; // 1 is default
13811
+ });
13812
+ }
13813
+
13814
+ var FrameData = /*#__PURE__*/Object.freeze({
13815
+ __proto__: null,
13816
+ getFrameData: getFrameData,
13817
+ getFrameLayerData: getFrameLayerData,
13818
+ getFrameSize: getFrameSize,
13819
+ findFrameLayerInDataset: findFrameLayerInDataset,
13820
+ findFrameDataset: findFrameDataset,
13821
+ findFrameLayer: findFrameLayer,
13822
+ getFrameLayerBounds: getFrameLayerBounds,
13823
+ getMapFrameMetersPerPixel: getMapFrameMetersPerPixel,
13824
+ calcOutputSizeInPixels: calcOutputSizeInPixels,
13825
+ parseMarginOption: parseMarginOption
13826
+ });
13827
+
13610
13828
  // Apply rotation, scale and/or shift to some or all of the features in a dataset
13611
13829
  //
13612
13830
  cmd.affine = function(targetLayers, dataset, opts) {
@@ -13744,6 +13962,14 @@
13744
13962
  return p.length > 2 && p[2] == 'C';
13745
13963
  }
13746
13964
 
13965
+ function stringifyPolygonCoords(coords) {
13966
+ var parts = [];
13967
+ for (var i=0; i<coords.length; i++) {
13968
+ parts.push(stringifyLineStringCoords(coords[i]) + ' Z');
13969
+ }
13970
+ return parts.length > 0 ? parts.join(' ') : '';
13971
+ }
13972
+
13747
13973
  function stringifyLineStringCoords(coords) {
13748
13974
  if (coords.length === 0) return '';
13749
13975
  var d = 'M';
@@ -13774,6 +14000,7 @@
13774
14000
 
13775
14001
  var SvgPathUtils = /*#__PURE__*/Object.freeze({
13776
14002
  __proto__: null,
14003
+ stringifyPolygonCoords: stringifyPolygonCoords,
13777
14004
  stringifyLineStringCoords: stringifyLineStringCoords
13778
14005
  });
13779
14006
 
@@ -14023,6 +14250,7 @@
14023
14250
  // null values indicate the lack of a function for parsing/identifying this property
14024
14251
  // (in which case a heuristic is used for distinguishing a string literal from an expression)
14025
14252
  var stylePropertyTypes = {
14253
+ css: null,
14026
14254
  class: 'classname',
14027
14255
  dx: 'measure',
14028
14256
  dy: 'measure',
@@ -14041,6 +14269,7 @@
14041
14269
  'stroke-dasharray': 'dasharray',
14042
14270
  'stroke-width': 'number',
14043
14271
  'stroke-opacity': 'number',
14272
+ 'stroke-miterlimit': 'number',
14044
14273
  'fill-opacity': 'number',
14045
14274
  'text-anchor': null
14046
14275
  };
@@ -14071,11 +14300,11 @@
14071
14300
  effect: null // e.g. "fade"
14072
14301
  }, stylePropertyTypes);
14073
14302
 
14074
- var commonProperties = 'class,opacity,stroke,stroke-width,stroke-dasharray,stroke-opacity,fill-opacity'.split(',');
14303
+ var commonProperties = 'css,class,opacity,stroke,stroke-width,stroke-dasharray,stroke-opacity,fill-opacity'.split(',');
14075
14304
 
14076
14305
  var propertiesBySymbolType = {
14077
14306
  polygon: utils.arrayToIndex(commonProperties.concat('fill', 'fill-pattern')),
14078
- polyline: utils.arrayToIndex(commonProperties.concat('stroke-linecap', 'stroke-linejoin')),
14307
+ polyline: utils.arrayToIndex(commonProperties.concat('stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit')),
14079
14308
  point: utils.arrayToIndex(commonProperties.concat('fill', 'r')),
14080
14309
  label: utils.arrayToIndex(commonProperties.concat(
14081
14310
  'fill,font-family,font-size,text-anchor,font-weight,font-style,letter-spacing,dominant-baseline'.split(',')))
@@ -14172,7 +14401,6 @@
14172
14401
  // treating the string as a literal value
14173
14402
  literalVal = strVal;
14174
14403
  }
14175
- // console.log("literalVal:", mightBeExpression(strVal, fields), strVal, fields)
14176
14404
  if (accessor) return accessor;
14177
14405
  if (literalVal !== null) return function(id) {return literalVal;};
14178
14406
  stop('Unexpected value for', svgName + ':', strVal);
@@ -14268,6 +14496,138 @@
14268
14496
  isSvgColor: isSvgColor
14269
14497
  });
14270
14498
 
14499
+ var geojsonImporters = {
14500
+ Point: importPoint,
14501
+ Polygon: importPolygon,
14502
+ LineString: importLineString,
14503
+ MultiPoint: importMultiPoint,
14504
+ MultiLineString: importMultiLineString,
14505
+ MultiPolygon: importMultiPolygon
14506
+ };
14507
+
14508
+ function importGeoJSONFeatures(features, opts) {
14509
+ opts = opts || {};
14510
+ return features.map(function(obj, i) {
14511
+ var geom = obj.type == 'Feature' ? obj.geometry : obj; // could be null
14512
+ var geomType = geom && geom.type;
14513
+ var msType = GeoJSON.translateGeoJSONType(geomType);
14514
+ var d = obj.properties || {};
14515
+ var svgObj = null;
14516
+ if (geomType && geom.coordinates) {
14517
+ svgObj = geojsonImporters[geomType](geom.coordinates, d);
14518
+ }
14519
+ if (!svgObj) {
14520
+ return {tag: 'g'}; // empty element
14521
+ } else if (msType == 'polyline' || msType == 'polygon') {
14522
+ applyStyleAttributes(svgObj, msType, d);
14523
+ } else if (msType == 'point' && isSimpleCircle(d)) {
14524
+ // kludge -- maintains bw compatibility/passes tests -- style attributes
14525
+ // are applied to the <g> container, 'r' property is applied to circle
14526
+ applyStyleAttributes(svgObj, msType, d, simpleCircleFilter);
14527
+ } else {
14528
+ // other point symbols: attributes are complicated, added downstream
14529
+ }
14530
+ if ('id' in obj) {
14531
+ if (!svgObj.properties) {
14532
+ svgObj.properties = {};
14533
+ }
14534
+ svgObj.properties.id = (opts.id_prefix || '') + obj.id;
14535
+ }
14536
+ return svgObj;
14537
+ });
14538
+ }
14539
+
14540
+ function importPoint(coords, rec) {
14541
+ rec = rec || {};
14542
+ if (isSimpleCircle(rec)) {
14543
+ return {
14544
+ tag: 'circle',
14545
+ properties: {
14546
+ cx: coords[0],
14547
+ cy: coords[1],
14548
+ r: rec.r
14549
+ }
14550
+ };
14551
+ }
14552
+ var o = renderPoint(rec, coords);
14553
+ if (o) o.properties.transform = getTransform(coords);
14554
+ return o;
14555
+ }
14556
+
14557
+ function simpleCircleFilter(k) {
14558
+ return k != 'r';
14559
+ }
14560
+
14561
+ // just a dot, no label or icon
14562
+ function isSimpleCircle(rec) {
14563
+ return rec && (rec.r > 0 && !rec['svg-symbol'] && !rec['label-text']);
14564
+ }
14565
+
14566
+ function importMultiPoint(coords, rec) {
14567
+ var children = [], p;
14568
+ for (var i=0; i<coords.length; i++) {
14569
+ p = importPoint(coords[i], rec);
14570
+ if (!p) continue;
14571
+ if (p.tag == 'g' && p.children) {
14572
+ children = children.concat(p.children);
14573
+ } else {
14574
+ children.push(p);
14575
+ }
14576
+ }
14577
+ return children.length > 0 ? {tag: 'g', children: children} : null;
14578
+ }
14579
+
14580
+ function importLineString(coords) {
14581
+ var d = stringifyLineStringCoords(coords);
14582
+ return {
14583
+ tag: 'path',
14584
+ properties: {d: d}
14585
+ };
14586
+ }
14587
+
14588
+ function importMultiLineString(coords) {
14589
+ var d = coords.map(stringifyLineStringCoords).join(' ');
14590
+ return {
14591
+ tag: 'path',
14592
+ properties: {d: d}
14593
+ };
14594
+ }
14595
+
14596
+ function importMultiPolygon(coords) {
14597
+ return importPolygon(flattenMultiPolygonCoords(coords));
14598
+ }
14599
+
14600
+ function flattenMultiPolygonCoords(coords) {
14601
+ return coords.reduce(function(memo, poly) {
14602
+ return memo.concat(poly);
14603
+ }, []);
14604
+ }
14605
+
14606
+ function importPolygon(coords) {
14607
+ if (coords.length === 0) return null;
14608
+ var o = {
14609
+ tag: 'path',
14610
+ properties: {
14611
+ d: stringifyPolygonCoords(coords)
14612
+ }
14613
+ };
14614
+ if (coords.length > 1) {
14615
+ o.properties['fill-rule'] = 'evenodd'; // support polygons with holes
14616
+ }
14617
+ return o;
14618
+ }
14619
+
14620
+ var GeojsonToSvg = /*#__PURE__*/Object.freeze({
14621
+ __proto__: null,
14622
+ importGeoJSONFeatures: importGeoJSONFeatures,
14623
+ importPoint: importPoint,
14624
+ importLineString: importLineString,
14625
+ importMultiLineString: importMultiLineString,
14626
+ importMultiPolygon: importMultiPolygon,
14627
+ flattenMultiPolygonCoords: flattenMultiPolygonCoords,
14628
+ importPolygon: importPolygon
14629
+ });
14630
+
14271
14631
  function toLabelString(val) {
14272
14632
  if (val || val === 0 || val === false) return String(val);
14273
14633
  return '';
@@ -14331,7 +14691,6 @@
14331
14691
 
14332
14692
  // convert data records (properties like svg-symbol, label-text, fill, r) to svg symbols
14333
14693
 
14334
-
14335
14694
  function getTransform(xy, scale) {
14336
14695
  var str = 'translate(' + roundToTenths(xy[0]) + ' ' + roundToTenths(xy[1]) + ')';
14337
14696
  if (scale && scale != 1) {
@@ -14348,7 +14707,8 @@
14348
14707
  square: square,
14349
14708
  image: image,
14350
14709
  group: group,
14351
- label: label
14710
+ label: label,
14711
+ offset: offset
14352
14712
  };
14353
14713
 
14354
14714
  // render label and/or point symbol
@@ -14377,16 +14737,24 @@
14377
14737
  return empty();
14378
14738
  }
14379
14739
 
14380
- function renderComplexSymbol(sym) {
14740
+ function renderComplexSymbol(sym, x, y) {
14381
14741
  if (utils.isString(sym)) {
14382
14742
  sym = JSON.parse(sym);
14383
14743
  }
14744
+ if (sym.tag) {
14745
+ // symbol appears to already use mapshaper's svg notation... pass through
14746
+ return sym;
14747
+ }
14384
14748
  var renderer = symbolRenderers[sym.type];
14385
14749
  if (!renderer) {
14386
14750
  message(sym.type ? 'Unknown symbol type: ' + sym.type : 'Symbol is missing a type property');
14387
14751
  return empty();
14388
14752
  }
14389
- return renderer(sym, 0, 0);
14753
+ var o = renderer(sym, x || 0, y || 0);
14754
+ if (sym.opacity) {
14755
+ o.properties.opacity = sym.opacity;
14756
+ }
14757
+ return o;
14390
14758
  }
14391
14759
 
14392
14760
  function empty() {
@@ -14406,7 +14774,13 @@
14406
14774
  }
14407
14775
 
14408
14776
  function label(d, x, y) {
14409
- return renderStyledLabel(d);
14777
+ var o = renderStyledLabel(d);
14778
+ if (x || y) {
14779
+ // set x, y here, rather than adding to dx, dy -- so dy, dy can
14780
+ // have CSS units (like ems)
14781
+ o.properties.transform = getTransform([x, y]);
14782
+ }
14783
+ return o;
14410
14784
  }
14411
14785
 
14412
14786
  function image(d, x, y) {
@@ -14417,8 +14791,8 @@
14417
14791
  properties: {
14418
14792
  width: w,
14419
14793
  height: h,
14420
- x: x - w / 2,
14421
- y: y - h / 2,
14794
+ x: (x || 0) - w / 2,
14795
+ y: (y || 0) - h / 2,
14422
14796
  href: d.href || ''
14423
14797
  }
14424
14798
  };
@@ -14448,6 +14822,7 @@
14448
14822
  return o;
14449
14823
  }
14450
14824
 
14825
+ // polyline coords are like GeoJSON MultiLineString coords: an array of 0 or more paths
14451
14826
  function polyline(d, x, y) {
14452
14827
  var coords = d.coordinates || [];
14453
14828
  var o = importMultiLineString(coords);
@@ -14455,6 +14830,7 @@
14455
14830
  return o;
14456
14831
  }
14457
14832
 
14833
+ // polygon coords are an array of rings (and holes), like flattened MultiPolygon coords
14458
14834
  function polygon(d, x, y) {
14459
14835
  var coords = d.coordinates || [];
14460
14836
  var o = importPolygon(coords);
@@ -14462,20 +14838,40 @@
14462
14838
  return o;
14463
14839
  }
14464
14840
 
14841
+ function offset(d, x, y) {
14842
+ var dx = (x || 0) + (d.dx || 0);
14843
+ var dy = (y || 0) + (d.dy || 0);
14844
+ return {
14845
+ tag: 'g',
14846
+ properties: {
14847
+ transform: getTransform([dx, dy])
14848
+ },
14849
+ children: []
14850
+ };
14851
+ }
14852
+
14465
14853
  function group(d, x, y) {
14466
- var parts = (d.parts || []).reduce(function(memo, o) {
14467
- var sym = renderSymbol(o, x, y);
14468
- if (d.chained) {
14854
+ var o = {
14855
+ tag: 'g',
14856
+ children: []
14857
+ };
14858
+ var children = o.children;
14859
+ (d.parts || []).forEach(function(o) {
14860
+ var sym = renderComplexSymbol(o, x, y);
14861
+ children.push(sym);
14862
+ //if (d.chained) {
14863
+ //if (o.chained) {
14864
+ if (o.type == 'line') {
14469
14865
  x += (o.dx || 0);
14470
14866
  y += (o.dy || 0);
14471
14867
  }
14472
- return memo.concat(sym);
14473
- }, []);
14474
- if (parts.length == 1) return parts[0];
14475
- return {
14476
- tag: 'g',
14477
- children: parts
14478
- };
14868
+ if (o.type == 'offset') {
14869
+ children = sym.children;
14870
+ x = y = 0;
14871
+ }
14872
+ });
14873
+ if (o.children.length == 1 && !o.properties) return o.children[0];
14874
+ return o;
14479
14875
  }
14480
14876
 
14481
14877
  var SvgSymbols = /*#__PURE__*/Object.freeze({
@@ -14485,424 +14881,186 @@
14485
14881
  renderPoint: renderPoint
14486
14882
  });
14487
14883
 
14488
- function importGeoJSONFeatures(features, opts) {
14489
- opts = opts || {};
14490
- return features.map(function(obj, i) {
14491
- var geom = obj.type == 'Feature' ? obj.geometry : obj; // could be null
14492
- var geomType = geom && geom.type;
14493
- var msType = GeoJSON.translateGeoJSONType(geomType);
14494
- var d = obj.properties || {};
14495
- var svgObj = null;
14496
- if (geomType && geom.coordinates) {
14497
- svgObj = geojsonImporters[geomType](geom.coordinates, d);
14498
- }
14499
- if (!svgObj) {
14500
- return {tag: 'g'}; // empty element
14501
- } else if (msType == 'polyline' || msType == 'polygon') {
14502
- applyStyleAttributes(svgObj, msType, d);
14503
- } else if (msType == 'point' && isSimpleCircle(d)) {
14504
- // kludge -- maintains bw compatibility/passes tests -- style attributes
14505
- // are applied to the <g> container, 'r' property is applied to circle
14506
- applyStyleAttributes(svgObj, msType, d, simpleCircleFilter);
14507
- } else {
14508
- // other point symbols: attributes are complicated, added downstream
14509
- }
14510
- if ('id' in obj) {
14511
- if (!svgObj.properties) {
14512
- svgObj.properties = {};
14513
- }
14514
- svgObj.properties.id = (opts.id_prefix || '') + obj.id;
14515
- }
14516
- return svgObj;
14517
- });
14518
- }
14519
-
14520
- function importPoint(coords, rec) {
14521
- rec = rec || {};
14522
- if (isSimpleCircle(rec)) {
14523
- return {
14524
- tag: 'circle',
14525
- properties: {
14526
- cx: coords[0],
14527
- cy: coords[1],
14528
- r: rec.r
14529
- }
14530
- };
14531
- }
14532
- var o = renderPoint(rec, coords);
14533
- if (o) o.properties.transform = getTransform(coords);
14534
- return o;
14535
- }
14536
-
14537
- function simpleCircleFilter(k) {
14538
- return k != 'r';
14539
- }
14540
-
14541
- // just a dot, no label or icon
14542
- function isSimpleCircle(rec) {
14543
- return rec && (rec.r > 0 && !rec['svg-symbol'] && !rec['label-text']);
14544
- }
14545
-
14546
- function importMultiPoint(coords, rec) {
14547
- var children = [], p;
14548
- for (var i=0; i<coords.length; i++) {
14549
- p = importPoint(coords[i], rec);
14550
- if (!p) continue;
14551
- if (p.tag == 'g' && p.children) {
14552
- children = children.concat(p.children);
14553
- } else {
14554
- children.push(p);
14555
- }
14556
- }
14557
- return children.length > 0 ? {tag: 'g', children: children} : null;
14558
- }
14559
-
14560
- function importMultiPath(coords, importer) {
14561
- var o;
14562
- for (var i=0; i<coords.length; i++) {
14563
- if (i === 0) {
14564
- o = importer(coords[i]);
14565
- } else {
14566
- o.properties.d += ' ' + importer(coords[i]).properties.d;
14567
- }
14884
+ cmd.scalebar = function(catalog, opts) {
14885
+ var frame = findFrameDataset(catalog);
14886
+ var obj, lyr;
14887
+ if (!frame) {
14888
+ stop('Missing a map frame');
14568
14889
  }
14569
- return o;
14570
- }
14890
+ lyr = getScalebarLayer(opts);
14891
+ frame.layers.push(lyr);
14892
+ };
14571
14893
 
14572
- function importLineString(coords) {
14573
- var d = stringifyLineStringCoords(coords);
14894
+ function getScalebarLayer(opts) {
14895
+ var obj = utils.defaults({type: 'scalebar'}, opts);
14574
14896
  return {
14575
- tag: 'path',
14576
- properties: {d: d}
14897
+ name: opts.name || 'scalebar',
14898
+ data: new DataTable([obj])
14577
14899
  };
14578
14900
  }
14579
14901
 
14580
- function importMultiLineString(coords) {
14581
- var d = coords.map(stringifyLineStringCoords).join(' ');
14582
- return {
14583
- tag: 'path',
14584
- properties: {d: d}
14902
+ // TODO: generalize to other kinds of furniture as they are developed
14903
+ function getScalebarPosition(d) {
14904
+ var opts = { // defaults
14905
+ valign: 'top',
14906
+ halign: 'left',
14907
+ voffs: 10,
14908
+ hoffs: 10
14585
14909
  };
14586
- }
14587
-
14588
- function importPolygon(coords) {
14589
- var d, o;
14590
- for (var i=0; i<coords.length; i++) {
14591
- d = o ? o.properties.d + ' ' : '';
14592
- o = importLineString(coords[i]);
14593
- o.properties.d = d + o.properties.d + ' Z';
14910
+ if (+d.left > 0) {
14911
+ opts.hoffs = +d.left;
14594
14912
  }
14595
- if (coords.length > 1) {
14596
- o.properties['fill-rule'] = 'evenodd'; // support polygons with holes
14913
+ if (+d.top > 0) {
14914
+ opts.voffs = +d.top;
14597
14915
  }
14598
- return o;
14916
+ if (+d.right > 0) {
14917
+ opts.hoffs = +d.right;
14918
+ opts.halign = 'right';
14919
+ }
14920
+ if (+d.bottom > 0) {
14921
+ opts.voffs = +d.bottom;
14922
+ opts.valign = 'bottom';
14923
+ }
14924
+ return opts;
14599
14925
  }
14600
14926
 
14601
- var geojsonImporters = {
14602
- Point: importPoint,
14603
- Polygon: importPolygon,
14604
- LineString: importLineString,
14605
- MultiPoint: function(coords, rec) {
14606
- return importMultiPoint(coords, rec);
14607
- },
14608
- MultiLineString: function(coords) {
14609
- return importMultiPath(coords, importLineString);
14610
- },
14611
- MultiPolygon: function(coords) {
14612
- return importMultiPath(coords, importPolygon);
14613
- }
14614
- };
14927
+ furnitureRenderers.scalebar = renderScalebar;
14615
14928
 
14616
- var GeojsonToSvg = /*#__PURE__*/Object.freeze({
14617
- __proto__: null,
14618
- importGeoJSONFeatures: importGeoJSONFeatures,
14619
- importPoint: importPoint,
14620
- importLineString: importLineString,
14621
- importMultiLineString: importMultiLineString,
14622
- importPolygon: importPolygon
14623
- });
14624
-
14625
- /* require mapshaper-rectangle, mapshaper-furniture */
14929
+ function renderScalebar(d, frame) {
14930
+ var pos = getScalebarPosition(d);
14931
+ var metersPerPx = getMapFrameMetersPerPixel(frame);
14932
+ var label = d.label_text || getAutoScalebarLabel(frame.width, metersPerPx);
14933
+ var scalebarKm = parseScalebarLabelToKm(label);
14934
+ var barHeight = 3;
14935
+ var labelOffs = 4;
14936
+ var fontSize = +d.font_size || 12;
14937
+ var width = Math.round(scalebarKm / metersPerPx * 1000);
14938
+ var height = Math.round(barHeight + labelOffs + fontSize * 0.8);
14939
+ var labelPos = d.label_position == 'top' ? 'top' : 'bottom';
14940
+ var anchorX = pos.halign == 'left' ? 0 : width;
14941
+ var anchorY = barHeight + labelOffs;
14942
+ var dx = pos.halign == 'right' ? frame.width - width - pos.hoffs : pos.hoffs;
14943
+ var dy = pos.valign == 'bottom' ? frame.height - height - pos.voffs : pos.voffs;
14626
14944
 
14627
- cmd.frame = function(catalog, source, opts) {
14628
- var size, bounds, tmp, dataset;
14629
- if (+opts.width > 0 === false && +opts.pixels > 0 === false) {
14630
- stop("Missing a width or area");
14631
- }
14632
- if (opts.width && opts.height) {
14633
- opts = utils.extend({}, opts);
14634
- // Height is a string containing either a number or a
14635
- // comma-sep. pair of numbers (range); here we convert height to
14636
- // an aspect-ratio parameter for the rectangle() function
14637
- opts.aspect_ratio = getAspectRatioArg(opts.width, opts.height);
14638
- // TODO: currently returns max,min aspect ratio, should return in min,max order
14639
- // (rectangle() function should handle max,min argument correctly now anyway)
14640
- }
14641
- tmp = cmd.rectangle(source, opts);
14642
- bounds = getDatasetBounds(tmp);
14643
- if (probablyDecimalDegreeBounds(bounds)) {
14644
- stop('Frames require projected, not geographical coordinates');
14645
- } else if (!getDatasetCRS(tmp)) {
14646
- message('Warning: missing projection data. Assuming coordinates are meters and k (scale factor) is 1');
14647
- }
14648
- size = getFrameSize(bounds, opts);
14649
- if (size[0] > 0 === false) {
14650
- stop('Missing a valid frame width');
14651
- }
14652
- if (size[1] > 0 === false) {
14653
- stop('Missing a valid frame height');
14945
+ if (!frame.crs) {
14946
+ message('Unable to render a scalebar: unknown CRS.');
14947
+ return [];
14654
14948
  }
14655
- dataset = {info: {}, layers:[{
14656
- name: opts.name || 'frame',
14657
- data: new DataTable([{
14658
- width: size[0],
14659
- height: size[1],
14660
- bbox: bounds.toArray(),
14661
- type: 'frame'
14662
- }])
14663
- }]};
14664
- catalog.addDataset(dataset);
14665
- };
14666
14949
 
14667
- // Convert width and height args to aspect ratio arg for the rectangle() function
14668
- function getAspectRatioArg(widthArg, heightArg) {
14669
- // heightArg is a string containing either a number or a
14670
- // comma-sep. pair of numbers (range);
14671
- return heightArg.split(',').map(function(opt) {
14672
- var height = Number(opt),
14673
- width = Number(widthArg);
14674
- if (!opt) return '';
14675
- return width / height;
14676
- }).reverse().join(',');
14677
- }
14678
-
14679
- function getFrameSize(bounds, opts) {
14680
- var aspectRatio = bounds.width() / bounds.height();
14681
- var height, width;
14682
- if (opts.pixels) {
14683
- width = Math.sqrt(+opts.pixels * aspectRatio);
14684
- } else {
14685
- width = +opts.width;
14950
+ if (labelPos == 'top') {
14951
+ anchorY = -labelOffs;
14952
+ dy += Math.round(labelOffs + fontSize * 0.8);
14686
14953
  }
14687
- height = width / aspectRatio;
14688
- return [Math.round(width), Math.round(height)];
14689
- }
14690
14954
 
14691
- function getDatasetDisplayBounds(dataset) {
14692
- var frameLyr = findFrameLayerInDataset(dataset);
14693
- if (frameLyr) {
14694
- // TODO: check for coordinate issues (non-intersection with other layers, etc)
14695
- return getFrameLayerBounds(frameLyr);
14955
+ if (width > 0 === false) {
14956
+ stop("Null scalebar length");
14696
14957
  }
14697
- return getDatasetBounds(dataset);
14698
- }
14699
-
14700
- // @lyr dataset layer
14701
- function isFrameLayer(lyr) {
14702
- return getFurnitureLayerType(lyr) == 'frame';
14958
+ var barObj = {
14959
+ tag: 'rect',
14960
+ properties: {
14961
+ fill: 'black',
14962
+ x: 0,
14963
+ y: 0,
14964
+ width: width,
14965
+ height: barHeight
14966
+ }
14967
+ };
14968
+ var labelOpts = {
14969
+ 'label-text': label,
14970
+ 'font-size': fontSize,
14971
+ 'text-anchor': pos.halign == 'left' ? 'start': 'end',
14972
+ 'dominant-baseline': labelPos == 'top' ? 'auto' : 'hanging'
14973
+ //// 'dominant-baseline': labelPos == 'top' ? 'text-after-edge' : 'text-before-edge'
14974
+ // 'text-after-edge' is buggy in Safari and unsupported by Illustrator,
14975
+ // so I'm using 'hanging' and 'auto', which seem to be well supported.
14976
+ // downside: requires a kludgy multiplier to calculate scalebar height (see above)
14977
+ };
14978
+ var labelObj = symbolRenderers.label(labelOpts, anchorX, anchorY);
14979
+ var g = {
14980
+ tag: 'g',
14981
+ children: [barObj, labelObj],
14982
+ properties: {
14983
+ transform: 'translate(' + dx + ' ' + dy + ')'
14984
+ }
14985
+ };
14986
+ return [g];
14703
14987
  }
14704
14988
 
14705
- function findFrameLayerInDataset(dataset) {
14706
- return utils.find(dataset.layers, function(lyr) {
14707
- return isFrameLayer(lyr);
14708
- });
14989
+ function getAutoScalebarLabel(mapWidth, metersPerPx) {
14990
+ var minWidth = 70; // 100; // TODO: vary min size based on map width
14991
+ var minKm = metersPerPx * minWidth / 1000;
14992
+ // note: removed 1.5 12 and 1,200
14993
+ var options = ('1/8 1/5 1/4 1/2 1 2 3 4 5 8 10 15 20 25 30 40 50 75 ' +
14994
+ '100 150 200 250 300 350 400 500 750 1,000 1,500 2,000 ' +
14995
+ '2,500 3,000 4,000 5,000').split(' ');
14996
+ return options.reduce(function(memo, str) {
14997
+ if (memo) return memo;
14998
+ var label = formatDistanceLabelAsMiles(str);
14999
+ if (parseScalebarLabelToKm(label) > minKm) {
15000
+ return label;
15001
+ }
15002
+ }, null) || '';
14709
15003
  }
14710
15004
 
14711
- function findFrameDataset(catalog) {
14712
- var target = utils.find(catalog.getLayers(), function(o) {
14713
- return isFrameLayer(o.layer);
14714
- });
14715
- return target ? target.dataset : null;
15005
+ function formatDistanceLabelAsMiles(str) {
15006
+ var num = parseScalebarNumber(str);
15007
+ return str + (num > 1 ? ' MILES' : ' MILE');
14716
15008
  }
14717
15009
 
14718
- function findFrameLayer(catalog) {
14719
- var target = utils.find(catalog.getLayers(), function(o) {
14720
- return isFrameLayer(o.layer);
14721
- });
14722
- return target && target.layer || null;
15010
+ // See test/mapshaper-scalebar.js for examples of supported formats
15011
+ function parseScalebarLabelToKm(str) {
15012
+ var units = parseScalebarUnits(str);
15013
+ var value = parseScalebarNumber(str);
15014
+ if (!units || !value) return NaN;
15015
+ return units == 'mile' ? value * 1.60934 : value;
14723
15016
  }
14724
15017
 
14725
- function getFrameLayerBounds(lyr) {
14726
- return new Bounds(getFurnitureLayerData(lyr).bbox);
15018
+ function parseScalebarUnits(str) {
15019
+ var isMiles = /miles?$/.test(str.toLowerCase());
15020
+ var isKm = /(km|kilometers?|kilometres?)$/.test(str.toLowerCase());
15021
+ return isMiles && 'mile' || isKm && 'km' || '';
14727
15022
  }
14728
15023
 
14729
-
14730
- // @data frame data, including crs property if available
14731
- // Returns a single value: the ratio or
14732
- function getMapFrameMetersPerPixel(data) {
14733
- var bounds = new Bounds(data.bbox);
14734
- var k, toMeters, metersPerPixel;
14735
- if (data.crs) {
14736
- // TODO: handle CRS without inverse projections
14737
- // scale factor is the ratio of coordinate distance to true distance at a point
14738
- k = getScaleFactorAtXY(bounds.centerX(), bounds.centerY(), data.crs);
14739
- toMeters = data.crs.to_meter;
15024
+ function parseScalebarNumber(str) {
15025
+ var fractionRxp = /^([0-9]+) ?\/ ?([0-9]+)/;
15026
+ var match, value;
15027
+ str = str.replace(/[\s]/g, '').replace(/,/g, '');
15028
+ if (fractionRxp.test(str)) {
15029
+ match = fractionRxp.exec(str);
15030
+ value = +match[1] / +match[2];
14740
15031
  } else {
14741
- // Assuming coordinates are meters and k is 1 (not safe)
14742
- // A warning should be displayed when relevant furniture element is created
14743
- k = 1;
14744
- toMeters = 1;
15032
+ value = parseFloat(str);
14745
15033
  }
14746
- metersPerPixel = bounds.width() / k * toMeters / data.width;
14747
- return metersPerPixel;
15034
+ return value > 0 && value < Infinity ? value : NaN;
14748
15035
  }
14749
15036
 
14750
- furnitureRenderers.frame = function(d) {
14751
- var lineWidth = 1,
14752
- // inset stroke by half of line width
14753
- off = lineWidth / 2,
14754
- obj = importPolygon([[[off, off], [off, d.height - off],
14755
- [d.width - off, d.height - off],
14756
- [d.width - off, off], [off, off]]]);
14757
- utils.extend(obj.properties, {
14758
- fill: 'none',
14759
- stroke: d.stroke || 'black',
14760
- 'stroke-width': d['stroke-width'] || lineWidth
14761
- });
14762
- return [obj];
14763
- };
14764
-
14765
- var Frame = /*#__PURE__*/Object.freeze({
15037
+ var Scalebar = /*#__PURE__*/Object.freeze({
14766
15038
  __proto__: null,
14767
- getAspectRatioArg: getAspectRatioArg,
14768
- getFrameSize: getFrameSize,
14769
- findFrameLayerInDataset: findFrameLayerInDataset,
14770
- findFrameDataset: findFrameDataset,
14771
- findFrameLayer: findFrameLayer,
14772
- getFrameLayerBounds: getFrameLayerBounds,
14773
- getMapFrameMetersPerPixel: getMapFrameMetersPerPixel
15039
+ getScalebarLayer: getScalebarLayer,
15040
+ renderScalebar: renderScalebar,
15041
+ formatDistanceLabelAsMiles: formatDistanceLabelAsMiles,
15042
+ parseScalebarLabelToKm: parseScalebarLabelToKm
14774
15043
  });
14775
15044
 
14776
15045
  function transformDatasetToPixels(dataset, opts) {
14777
- var frameLyr = findFrameLayerInDataset(dataset);
14778
- var bounds, bounds2, fwd, frameData;
14779
- if (frameLyr) {
14780
- // TODO: handle options like width, height margin when a frame is present
14781
- // TODO: check that aspect ratios match
14782
- frameData = getFurnitureLayerData(frameLyr);
14783
- bounds = new Bounds(frameData.bbox);
14784
- bounds2 = new Bounds(0, 0, frameData.width, frameData.height);
14785
- } else {
14786
- bounds = getDatasetBounds(dataset);
14787
- bounds2 = calcOutputSizeInPixels(bounds, opts);
14788
- }
14789
- fwd = bounds.getTransform(bounds2, opts.invert_y);
14790
- transformPoints(dataset, function(x, y) {
14791
- return fwd.transform(x, y);
14792
- });
14793
- return [Math.round(bounds2.width()), Math.round(bounds2.height()) || 1];
15046
+ var frame = getFrameData(dataset, opts);
15047
+ fitDatasetToFrame(dataset, frame, opts);
15048
+ return [frame.width, frame.height];
14794
15049
  }
14795
15050
 
14796
- function parseMarginOption(opt) {
14797
- var str = utils.isNumber(opt) ? String(opt) : opt || '';
14798
- var margins = str.trim().split(/[, ] */);
14799
- if (margins.length == 1) margins.push(margins[0]);
14800
- if (margins.length == 2) margins.push(margins[0], margins[1]);
14801
- if (margins.length == 3) margins.push(margins[2]);
14802
- return margins.map(function(str) {
14803
- var px = parseFloat(str);
14804
- return isNaN(px) ? 1 : px; // 1 is default
15051
+ function fitDatasetToFrame(dataset, frame, opts) {
15052
+ var bounds = new Bounds(frame.bbox);
15053
+ var bounds2 = new Bounds(0, 0, frame.width, frame.height);
15054
+ var fwd = bounds.getTransform(bounds2, opts.invert_y);
15055
+ transformPoints(dataset, function(x, y) {
15056
+ return fwd.transform(x, y);
14805
15057
  });
14806
15058
  }
14807
15059
 
14808
- // bounds: Bounds object containing bounds of content in geographic coordinates
14809
- // returns Bounds object containing bounds of pixel output
14810
- // side effect: bounds param is modified to match the output frame
14811
- function calcOutputSizeInPixels(bounds, opts) {
14812
- var padX = 0,
14813
- padY = 0,
14814
- offX = 0,
14815
- offY = 0,
14816
- width = bounds.width(),
14817
- height = bounds.height(),
14818
- margins = parseMarginOption(opts.margin),
14819
- marginX = margins[0] + margins[2],
14820
- marginY = margins[1] + margins[3],
14821
- // TODO: add option to tweak alignment of content when both width and height are given
14822
- wx = 0.5, // how padding is distributed horizontally (0: left aligned, 0.5: centered, 1: right aligned)
14823
- wy = 0.5, // vertical padding distribution
14824
- widthPx, heightPx, size, kx, ky;
14825
-
14826
- if (opts.fit_bbox) {
14827
- // scale + shift content to fit within a bbox
14828
- offX = opts.fit_bbox[0];
14829
- offY = opts.fit_bbox[1];
14830
- widthPx = opts.fit_bbox[2] - offX;
14831
- heightPx = opts.fit_bbox[3] - offY;
14832
- if (width / height > widthPx / heightPx) {
14833
- // data is wider than fit box...
14834
- // scale the data to fit widthwise
14835
- heightPx = 0;
14836
- } else {
14837
- widthPx = 0; // fit the data to the height
14838
- }
14839
- marginX = marginY = 0; // TODO: support margins
14840
-
14841
- } else if (opts.svg_scale > 0) {
14842
- // alternative to using a fixed width (e.g. when generating multiple files
14843
- // at a consistent geographic scale)
14844
- widthPx = width / opts.svg_scale + marginX;
14845
- heightPx = 0;
14846
- } else if (+opts.pixels) {
14847
- size = getFrameSize(bounds, opts);
14848
- widthPx = size[0];
14849
- heightPx = size[1];
14850
- } else {
14851
- heightPx = opts.height || 0;
14852
- widthPx = opts.width || (heightPx > 0 ? 0 : 800); // 800 is default width
14853
- }
14854
-
14855
- if (heightPx > 0) {
14856
- // vertical meters per pixel to fit height param
14857
- ky = (height || width || 1) / (heightPx - marginY);
14858
- }
14859
- if (widthPx > 0) {
14860
- // horizontal meters per pixel to fit width param
14861
- kx = (width || height || 1) / (widthPx - marginX);
14862
- }
14863
-
14864
- if (!widthPx) { // heightPx and ky are defined, set width to match
14865
- kx = ky;
14866
- widthPx = width > 0 ? marginX + width / kx : heightPx; // export square graphic if content has 0 width (reconsider this?)
14867
- } else if (!heightPx) { // widthPx and kx are set, set height to match
14868
- ky = kx;
14869
- heightPx = height > 0 ? marginY + height / ky : widthPx;
14870
- // limit height if max_height is defined
14871
- if (opts.max_height > 0 && heightPx > opts.max_height) {
14872
- ky = kx * heightPx / opts.max_height;
14873
- heightPx = opts.max_height;
14874
- }
14875
- }
14876
-
14877
- if (kx > ky) { // content is wide -- need to pad vertically
14878
- ky = kx;
14879
- padY = ky * (heightPx - marginY) - height;
14880
- } else if (ky > kx) { // content is tall -- need to pad horizontally
14881
- kx = ky;
14882
- padX = kx * (widthPx - marginX) - width;
14883
- }
14884
-
14885
- bounds.padBounds(
14886
- margins[0] * kx + padX * wx,
14887
- margins[1] * ky + padY * wy,
14888
- margins[2] * kx + padX * (1 - wx),
14889
- margins[3] * ky + padY * (1 - wy));
14890
-
14891
- if (!(widthPx > 0 && heightPx > 0)) {
14892
- error("Missing valid height and width parameters");
14893
- }
14894
- if (!(kx === ky && kx > 0)) {
14895
- error("Missing valid margin parameters");
14896
- }
14897
-
14898
- return new Bounds(offX, offY, widthPx + offX, heightPx + offY);
14899
- }
14900
-
14901
15060
  var PixelTransform = /*#__PURE__*/Object.freeze({
14902
15061
  __proto__: null,
14903
15062
  transformDatasetToPixels: transformDatasetToPixels,
14904
- parseMarginOption: parseMarginOption,
14905
- calcOutputSizeInPixels: calcOutputSizeInPixels
15063
+ fitDatasetToFrame: fitDatasetToFrame
14906
15064
  });
14907
15065
 
14908
15066
  function stringify(obj) {
@@ -14959,6 +15117,9 @@
14959
15117
  if (key == 'href') {
14960
15118
  key = 'xlink:href';
14961
15119
  }
15120
+ if (key == 'css') {
15121
+ key = 'style'; // inline style
15122
+ }
14962
15123
  return memo + ' ' + key + '="' + stringEscape(strval) + '"';
14963
15124
  }, '');
14964
15125
  }
@@ -15077,6 +15238,7 @@
15077
15238
  return svg;
15078
15239
  }
15079
15240
 
15241
+ // href: A URL or a local path
15080
15242
  // TODO: download SVG files asynchronously
15081
15243
  // (currently, files are downloaded synchronously, which is obviously undesirable)
15082
15244
  //
@@ -15084,7 +15246,7 @@
15084
15246
  var content;
15085
15247
  if (href.indexOf('http') === 0) {
15086
15248
  content = fetchFileSync(href);
15087
- } else if (require('fs').existsSync(href)) { // assume href is a relative path
15249
+ } else if (require('fs').existsSync(href)) {
15088
15250
  content = require('fs').readFileSync(href, 'utf8');
15089
15251
  } else {
15090
15252
  stop("Invalid SVG location:", href);
@@ -15125,12 +15287,11 @@
15125
15287
  }
15126
15288
  */
15127
15289
 
15128
- //
15129
15290
  //
15130
15291
  function exportSVG(dataset, opts) {
15131
15292
  var namespace = 'xmlns="http://www.w3.org/2000/svg"';
15132
15293
  var defs = [];
15133
- var size, svg;
15294
+ var frame, svg, layers;
15134
15295
  var style = '';
15135
15296
 
15136
15297
  // kludge for map keys
@@ -15147,62 +15308,70 @@
15147
15308
  } else {
15148
15309
  dataset = copyDataset(dataset); // Modify a copy of the dataset
15149
15310
  }
15311
+
15150
15312
  // invert_y setting for screen coordinates and geojson polygon generation
15151
15313
  utils.extend(opts, {invert_y: true});
15152
- size = transformCoordsForSVG(dataset, opts);
15314
+ frame = getFrameData(dataset, opts);
15315
+ fitDatasetToFrame(dataset, frame, opts);
15316
+ setCoordinatePrecision(dataset, opts.precision || 0.0001);
15153
15317
 
15154
15318
  // error if one or more svg_data fields are not present in any layers
15155
15319
  if (opts.svg_data) validateSvgDataFields(dataset.layers, opts.svg_data);
15156
15320
 
15157
- svg = dataset.layers.map(function(lyr) {
15158
- var obj = exportLayerForSVG(lyr, dataset, opts);
15321
+ layers = dataset.layers;
15322
+ if (opts.scalebar) {
15323
+ layers.push(getScalebarLayer({})); // default options
15324
+ }
15325
+ svg = layers.map(function(lyr) {
15326
+ var obj;
15327
+ if (layerHasFurniture(lyr)) {
15328
+ obj = exportFurnitureForSVG(lyr, frame, opts);
15329
+ } else {
15330
+ obj = exportLayerForSVG(lyr, dataset, opts);
15331
+ }
15159
15332
  convertPropertiesToDefinitions(obj, defs);
15160
15333
  return stringify(obj);
15161
15334
  }).join('\n');
15335
+
15162
15336
  if (defs.length > 0) {
15163
15337
  svg = '<defs>\n' + utils.pluck(defs, 'svg').join('') + '</defs>\n' + svg;
15164
15338
  }
15339
+
15165
15340
  if (svg.includes('xlink:')) {
15166
15341
  namespace += ' xmlns:xlink="http://www.w3.org/1999/xlink"';
15167
15342
  }
15343
+
15344
+ // default line style properties
15168
15345
  var capStyle = opts.default_linecap || 'round';
15346
+ var lineProps = `stroke-linecap="${capStyle}" stroke-linejoin="round"`;
15347
+ if (svg.includes('stroke-linejoin="miter"')) {
15348
+ // the default limit in Illustrator seems to be 10 -- too large for mapping
15349
+ // (Mapbox uses 2 as the default in their styles)
15350
+ lineProps += ' stroke-miterlimit="2"';
15351
+ }
15169
15352
  var template = `<?xml version="1.0"?>
15170
- <svg ${namespace} version="1.2" baseProfile="tiny" width="%d" height="%d" viewBox="%s %s %s %s" stroke-linecap="${capStyle}" stroke-linejoin="round">${style}
15353
+ <svg ${namespace} version="1.2" baseProfile="tiny" width="%d" height="%d" viewBox="%s %s %s %s" ${lineProps}>${style}
15171
15354
  ${svg}
15172
15355
  </svg>`;
15173
- svg = utils.format(template,size[0], size[1], 0, 0, size[0], size[1]);
15356
+ svg = utils.format(template, frame.width, frame.height, 0, 0, frame.width, frame.height);
15174
15357
  return [{
15175
15358
  content: svg,
15176
15359
  filename: opts.file || getOutputFileBase(dataset) + '.svg'
15177
15360
  }];
15178
15361
  }
15179
15362
 
15180
- function transformCoordsForSVG(dataset, opts) {
15181
- var size = transformDatasetToPixels(dataset, opts);
15182
- var precision = opts.precision || 0.0001;
15183
- setCoordinatePrecision(dataset, precision);
15184
- return size;
15363
+ function exportFurnitureForSVG(lyr, frame, opts) {
15364
+ var layerObj = getEmptyLayerForSVG(lyr, opts);
15365
+ layerObj.children = importFurniture(getFurnitureLayerData(lyr), frame);
15366
+ return layerObj;
15185
15367
  }
15186
15368
 
15187
15369
  function exportLayerForSVG(lyr, dataset, opts) {
15188
15370
  var layerObj = getEmptyLayerForSVG(lyr, opts);
15189
- if (layerHasFurniture(lyr)) {
15190
- layerObj.children = exportFurnitureForSVG(lyr, dataset, opts);
15191
- } else {
15192
- layerObj.children = exportSymbolsForSVG(lyr, dataset, opts);
15193
- }
15371
+ layerObj.children = exportSymbolsForSVG(lyr, dataset, opts);
15194
15372
  return layerObj;
15195
15373
  }
15196
15374
 
15197
- function exportFurnitureForSVG(lyr, dataset, opts) {
15198
- var frameLyr = findFrameLayerInDataset(dataset);
15199
- var frameData;
15200
- if (!frameLyr) return [];
15201
- frameData = getFurnitureLayerData(frameLyr);
15202
- frameData.crs = getDatasetCRS(dataset); // required by e.g. scalebar
15203
- return importFurniture(getFurnitureLayerData(lyr), frameData);
15204
- }
15205
-
15206
15375
  function exportSymbolsForSVG(lyr, dataset, opts) {
15207
15376
  // TODO: convert geojson features one at a time
15208
15377
  var d = utils.defaults({layers: [lyr]}, dataset);
@@ -15357,6 +15526,7 @@ ${svg}
15357
15526
  var Svg = /*#__PURE__*/Object.freeze({
15358
15527
  __proto__: null,
15359
15528
  exportSVG: exportSVG,
15529
+ exportFurnitureForSVG: exportFurnitureForSVG,
15360
15530
  exportLayerForSVG: exportLayerForSVG,
15361
15531
  validateSvgDataFields: validateSvgDataFields,
15362
15532
  exportDataAttributesForSVG: exportDataAttributesForSVG,
@@ -19252,6 +19422,10 @@ ${svg}
19252
19422
  .option('id-prefix', {
19253
19423
  describe: '[SVG] prefix for namespacing layer and feature ids'
19254
19424
  })
19425
+ .option('scalebar', {
19426
+ type: 'flag',
19427
+ // describe: '[SVG] add a scalebar showing scale at the center of the map'
19428
+ })
19255
19429
  .option('delimiter', {
19256
19430
  describe: '[CSV] field delimiter'
19257
19431
  })
@@ -20445,14 +20619,21 @@ ${svg}
20445
20619
  .option('stroke', {})
20446
20620
  .option('stroke-width', {})
20447
20621
  .option('fill', {
20448
- describe: 'symbol fill color'
20622
+ describe: 'symbol fill color (filled symbols only)'
20623
+ })
20624
+ .option('stroke', {
20625
+ describe: 'symbol line color (linear symbols only)'
20449
20626
  })
20450
- .option('polygons', {
20451
- describe: 'generate symbols as polygons instead of SVG objects',
20627
+ .option('stroke-width', {
20628
+ describe: 'symbol line width (linear symbols only)'
20629
+ })
20630
+ .option('geographic', {
20631
+ old_alias: 'polygons',
20632
+ describe: 'make geographic shapes instead of SVG objects',
20452
20633
  type: 'flag'
20453
20634
  })
20454
20635
  .option('pixel-scale', {
20455
- describe: 'set symbol scale in meters-per-pixel (for polygons option)',
20636
+ describe: 'set symbol scale in meters per pixel (geographic option)',
20456
20637
  type: 'number',
20457
20638
  })
20458
20639
  // .option('flipped', {
@@ -20489,6 +20670,9 @@ ${svg}
20489
20670
  .option('radii', {
20490
20671
  describe: '(ring) comma-sep. list of concentric radii, ascending order'
20491
20672
  })
20673
+ .option('arrow-style', {
20674
+ describe: '(arrow) options: stick, standard (default is standard)'
20675
+ })
20492
20676
  .option('length', {
20493
20677
  old_alias: 'arrow-length',
20494
20678
  describe: '(arrow) length of arrow in pixels'
@@ -32492,6 +32676,80 @@ ${svg}
32492
32676
  return parsed[0];
32493
32677
  }
32494
32678
 
32679
+ /* require mapshaper-rectangle, mapshaper-furniture */
32680
+
32681
+ cmd.frame = function(catalog, source, opts) {
32682
+ var size, bounds, tmp, dataset;
32683
+ if (+opts.width > 0 === false && +opts.pixels > 0 === false) {
32684
+ stop("Missing a width or area");
32685
+ }
32686
+ if (opts.width && opts.height) {
32687
+ opts = utils.extend({}, opts);
32688
+ // Height is a string containing either a number or a
32689
+ // comma-sep. pair of numbers (range); here we convert height to
32690
+ // an aspect-ratio parameter for the rectangle() function
32691
+ opts.aspect_ratio = getAspectRatioArg(opts.width, opts.height);
32692
+ // TODO: currently returns max,min aspect ratio, should return in min,max order
32693
+ // (rectangle() function should handle max,min argument correctly now anyway)
32694
+ }
32695
+ tmp = cmd.rectangle(source, opts);
32696
+ bounds = getDatasetBounds(tmp);
32697
+ if (probablyDecimalDegreeBounds(bounds)) {
32698
+ stop('Frames require projected, not geographical coordinates');
32699
+ } else if (!getDatasetCRS(tmp)) {
32700
+ message('Warning: missing projection data. Assuming coordinates are meters and k (scale factor) is 1');
32701
+ }
32702
+ size = getFrameSize(bounds, opts);
32703
+ if (size[0] > 0 === false) {
32704
+ stop('Missing a valid frame width');
32705
+ }
32706
+ if (size[1] > 0 === false) {
32707
+ stop('Missing a valid frame height');
32708
+ }
32709
+ dataset = {info: {}, layers:[{
32710
+ name: opts.name || 'frame',
32711
+ data: new DataTable([{
32712
+ width: size[0],
32713
+ height: size[1],
32714
+ bbox: bounds.toArray(),
32715
+ type: 'frame'
32716
+ }])
32717
+ }]};
32718
+ catalog.addDataset(dataset);
32719
+ };
32720
+
32721
+ // Convert width and height args to aspect ratio arg for the rectangle() function
32722
+ function getAspectRatioArg(widthArg, heightArg) {
32723
+ // heightArg is a string containing either a number or a
32724
+ // comma-sep. pair of numbers (range);
32725
+ return heightArg.split(',').map(function(opt) {
32726
+ var height = Number(opt),
32727
+ width = Number(widthArg);
32728
+ if (!opt) return '';
32729
+ return width / height;
32730
+ }).reverse().join(',');
32731
+ }
32732
+
32733
+ furnitureRenderers.frame = function(d) {
32734
+ var lineWidth = 1,
32735
+ // inset stroke by half of line width
32736
+ off = lineWidth / 2,
32737
+ obj = importPolygon([[[off, off], [off, d.height - off],
32738
+ [d.width - off, d.height - off],
32739
+ [d.width - off, off], [off, off]]]);
32740
+ utils.extend(obj.properties, {
32741
+ fill: 'none',
32742
+ stroke: d.stroke || 'black',
32743
+ 'stroke-width': d['stroke-width'] || lineWidth
32744
+ });
32745
+ return [obj];
32746
+ };
32747
+
32748
+ var Frame = /*#__PURE__*/Object.freeze({
32749
+ __proto__: null,
32750
+ getAspectRatioArg: getAspectRatioArg
32751
+ });
32752
+
32495
32753
  cmd.filterGeom = function(lyr, arcs, opts) {
32496
32754
  if (!layerHasGeometry(lyr)) {
32497
32755
  stop("Layer is missing geometry");
@@ -37379,153 +37637,6 @@ ${svg}
37379
37637
  }
37380
37638
  };
37381
37639
 
37382
- cmd.scalebar = function(catalog, opts) {
37383
- var frame = findFrameDataset(catalog);
37384
- var obj, lyr;
37385
- if (!frame) {
37386
- stop('Missing a map frame');
37387
- }
37388
- obj = utils.defaults({type: 'scalebar'}, opts);
37389
- lyr = {
37390
- name: opts.name || 'scalebar',
37391
- data: new DataTable([obj])
37392
- };
37393
- frame.layers.push(lyr);
37394
- };
37395
-
37396
- // TODO: generalize to other kinds of furniture as they are developed
37397
- function getScalebarPosition(d) {
37398
- var opts = { // defaults
37399
- valign: 'top',
37400
- halign: 'left',
37401
- voffs: 10,
37402
- hoffs: 10
37403
- };
37404
- if (+d.left > 0) {
37405
- opts.hoffs = +d.left;
37406
- }
37407
- if (+d.top > 0) {
37408
- opts.voffs = +d.top;
37409
- }
37410
- if (+d.right > 0) {
37411
- opts.hoffs = +d.right;
37412
- opts.halign = 'right';
37413
- }
37414
- if (+d.bottom > 0) {
37415
- opts.voffs = +d.bottom;
37416
- opts.valign = 'bottom';
37417
- }
37418
- return opts;
37419
- }
37420
-
37421
- furnitureRenderers.scalebar = function(d, frame) {
37422
- var pos = getScalebarPosition(d);
37423
- var metersPerPx = getMapFrameMetersPerPixel(frame);
37424
- var label = d.label_text || getAutoScalebarLabel(frame.width, metersPerPx);
37425
- var scalebarKm = parseScalebarLabelToKm(label);
37426
- var barHeight = 3;
37427
- var labelOffs = 4;
37428
- var fontSize = +d.font_size || 13;
37429
- var width = Math.round(scalebarKm / metersPerPx * 1000);
37430
- var height = Math.round(barHeight + labelOffs + fontSize * 0.8);
37431
- var labelPos = d.label_position == 'top' ? 'top' : 'bottom';
37432
- var anchorX = pos.halign == 'left' ? 0 : width;
37433
- var anchorY = barHeight + labelOffs;
37434
- var dx = pos.halign == 'right' ? frame.width - width - pos.hoffs : pos.hoffs;
37435
- var dy = pos.valign == 'bottom' ? frame.height - height - pos.voffs : pos.voffs;
37436
-
37437
- if (labelPos == 'top') {
37438
- anchorY = -labelOffs;
37439
- dy += Math.round(labelOffs + fontSize * 0.8);
37440
- }
37441
-
37442
- if (width > 0 === false) {
37443
- stop("Null scalebar length");
37444
- }
37445
- var barObj = {
37446
- tag: 'rect',
37447
- properties: {
37448
- fill: 'black',
37449
- x: 0,
37450
- y: 0,
37451
- width: width,
37452
- height: barHeight
37453
- }
37454
- };
37455
- var labelOpts = {
37456
- 'label-text': label,
37457
- 'font-size': fontSize,
37458
- 'text-anchor': pos.halign == 'left' ? 'start': 'end',
37459
- 'dominant-baseline': labelPos == 'top' ? 'auto' : 'hanging'
37460
- //// 'dominant-baseline': labelPos == 'top' ? 'text-after-edge' : 'text-before-edge'
37461
- // 'text-after-edge' is buggy in Safari and unsupported by Illustrator,
37462
- // so I'm using 'hanging' and 'auto', which seem to be well supported.
37463
- // downside: requires a kludgy multiplier to calculate scalebar height (see above)
37464
- };
37465
- var labelObj = symbolRenderers.label(labelOpts, anchorX, anchorY);
37466
- var g = {
37467
- tag: 'g',
37468
- children: [barObj, labelObj],
37469
- properties: {
37470
- transform: 'translate(' + dx + ' ' + dy + ')'
37471
- }
37472
- };
37473
- return [g];
37474
- };
37475
-
37476
- function getAutoScalebarLabel(mapWidth, metersPerPx) {
37477
- var minWidth = 100; // TODO: vary min size based on map width
37478
- var minKm = metersPerPx * minWidth / 1000;
37479
- 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 ' +
37480
- '100 150 200 250 300 350 400 500 750 1,000 1,200 1,500 2,000 ' +
37481
- '2,500 3,000 4,000 5,000').split(' ');
37482
- return options.reduce(function(memo, str) {
37483
- if (memo) return memo;
37484
- var label = formatDistanceLabelAsMiles(str);
37485
- if (parseScalebarLabelToKm(label) > minKm) {
37486
- return label;
37487
- }
37488
- }, null) || '';
37489
- }
37490
-
37491
- function formatDistanceLabelAsMiles(str) {
37492
- var num = parseScalebarNumber(str);
37493
- return str + (num > 1 ? ' MILES' : ' MILE');
37494
- }
37495
-
37496
- // See test/mapshaper-scalebar.js for examples of supported formats
37497
- function parseScalebarLabelToKm(str) {
37498
- var units = parseScalebarUnits(str);
37499
- var value = parseScalebarNumber(str);
37500
- if (!units || !value) return NaN;
37501
- return units == 'mile' ? value * 1.60934 : value;
37502
- }
37503
-
37504
- function parseScalebarUnits(str) {
37505
- var isMiles = /miles?$/.test(str.toLowerCase());
37506
- var isKm = /(km|kilometers?|kilometres?)$/.test(str.toLowerCase());
37507
- return isMiles && 'mile' || isKm && 'km' || '';
37508
- }
37509
-
37510
- function parseScalebarNumber(str) {
37511
- var fractionRxp = /^([0-9]+) ?\/ ?([0-9]+)/;
37512
- var match, value;
37513
- str = str.replace(/[\s]/g, '').replace(/,/g, '');
37514
- if (fractionRxp.test(str)) {
37515
- match = fractionRxp.exec(str);
37516
- value = +match[1] / +match[2];
37517
- } else {
37518
- value = parseFloat(str);
37519
- }
37520
- return value > 0 && value < Infinity ? value : NaN;
37521
- }
37522
-
37523
- var Scalebar = /*#__PURE__*/Object.freeze({
37524
- __proto__: null,
37525
- formatDistanceLabelAsMiles: formatDistanceLabelAsMiles,
37526
- parseScalebarLabelToKm: parseScalebarLabelToKm
37527
- });
37528
-
37529
37640
  cmd.shape = function(targetDataset, opts) {
37530
37641
  var geojson, dataset;
37531
37642
  if (opts.coordinates) {
@@ -38713,10 +38824,14 @@ ${svg}
38713
38824
 
38714
38825
  var roundCoord = getRoundingFunction(0.01);
38715
38826
 
38716
- function getSymbolColor(d) {
38827
+ function getSymbolFillColor(d) {
38717
38828
  return d.fill || 'magenta';
38718
38829
  }
38719
38830
 
38831
+ function getSymbolStrokeColor(d) {
38832
+ return d.stroke || d.fill || 'magenta';
38833
+ }
38834
+
38720
38835
  function getSymbolRadius(d) {
38721
38836
  if (d.radius === 0 || d.length === 0 || d.r === 0) return 0;
38722
38837
  return d.radius || d.length || d.r || 5; // use a default value
@@ -38790,57 +38905,104 @@ ${svg}
38790
38905
  points.push(p2);
38791
38906
  }
38792
38907
 
38793
- // export function getStickArrowCoords(d, totalLen) {
38794
- // var minStemRatio = getMinStemRatio(d);
38795
- // var headAngle = d['arrow-head-angle'] || 90;
38796
- // var curve = d['arrow-stem-curve'] || 0;
38797
- // var unscaledHeadWidth = d['arrow-head-width'] || 9;
38798
- // var unscaledHeadLen = getHeadLength(unscaledHeadWidth, headAngle);
38799
- // var scale = getScale(totalLen, unscaledHeadLen, minStemRatio);
38800
- // var headWidth = unscaledHeadWidth * scale;
38801
- // var headLen = unscaledHeadLen * scale;
38802
- // var tip = getStickArrowTip(totalLen, curve);
38803
- // var stem = [[0, 0], tip.concat()];
38804
- // if (curve) {
38805
- // addBezierArcControlPoints(stem, curve);
38806
- // }
38807
- // if (!headLen) return [stem];
38808
- // var head = [addPoints([-headWidth / 2, -headLen], tip), tip.concat(), addPoints([headWidth / 2, -headLen], tip)];
38908
+ function getStickArrowCoords(d) {
38909
+ return getArrowCoords(d, 'stick');
38910
+ }
38809
38911
 
38810
- // rotateCoords(stem, d.rotation);
38811
- // rotateCoords(head, d.rotation);
38812
- // return [stem, head];
38813
- // }
38912
+ function getFilledArrowCoords(d) {
38913
+ return getArrowCoords(d, 'standard');
38914
+ }
38814
38915
 
38815
- // function getStickArrowTip(totalLen, curve) {
38816
- // // curve/2 intersects the arrowhead at 90deg (trigonometry)
38817
- // var theta = Math.abs(curve/2) / 180 * Math.PI;
38818
- // var dx = totalLen * Math.sin(theta) * (curve > 0 ? -1 : 1);
38819
- // var dy = totalLen * Math.cos(theta);
38820
- // return [dx, dy];
38821
- // }
38916
+ function getArrowCoords(d, style) {
38917
+ var stickArrow = style == 'stick',
38918
+ // direction = d.rotation || d.direction || 0,
38919
+ direction = d.direction || 0, // rotation is an independent parameter
38920
+ stemTaper = d['stem-taper'] || 0,
38921
+ curvature = d['stem-curve'] || 0,
38922
+ size = calcArrowSize(d, stickArrow);
38923
+ if (!size) return null;
38924
+ var stemLen = size.stemLen,
38925
+ headLen = size.headLen,
38926
+ headDx = size.headWidth / 2,
38927
+ stemDx = size.stemWidth / 2,
38928
+ baseDx = stemDx * (1 - stemTaper),
38929
+ head, stem, coords, dx, dy;
38822
38930
 
38823
- // function addPoints(a, b) {
38824
- // return [a[0] + b[0], a[1] + b[1]];
38825
- // }
38931
+ if (curvature) {
38932
+ // make curved stem
38933
+ if (direction > 0) curvature = -curvature;
38934
+ var theta = Math.abs(curvature) / 180 * Math.PI;
38935
+ var sign = curvature > 0 ? 1 : -1;
38936
+ var ax = baseDx * Math.cos(theta); // rotate arrow base
38937
+ var ay = baseDx * Math.sin(theta) * -sign;
38938
+ dx = stemLen * Math.sin(theta / 2) * sign;
38939
+ dy = stemLen * Math.cos(theta / 2);
38940
+
38941
+ if (stickArrow) {
38942
+ stem = getCurvedStemCoords(-ax, -ay, dx, dy, theta);
38943
+ } else {
38944
+ var leftStem = getCurvedStemCoords(-ax, -ay, -stemDx + dx, dy, theta);
38945
+ var rightStem = getCurvedStemCoords(ax, ay, stemDx + dx, dy, theta);
38946
+ stem = leftStem.concat(rightStem.reverse());
38947
+ }
38948
+
38949
+ } else {
38950
+ // make straight stem
38951
+ dx = 0;
38952
+ dy = stemLen;
38953
+ if (stickArrow) {
38954
+ stem = [[0, 0], [0, stemLen]];
38955
+ } else {
38956
+ stem = [[-baseDx, 0], [baseDx, 0]];
38957
+ }
38958
+ }
38959
+
38960
+ if (stickArrow) {
38961
+ // make stick arrow
38962
+ head = [[-headDx + dx, stemLen - headLen], [dx, stemLen], [headDx + dx, stemLen - headLen]];
38963
+ coords = [stem, head]; // MultiLineString coords
38964
+ } else {
38965
+ // make filled arrow
38966
+ // coordinates go counter clockwise, starting from the leftmost head coordinate
38967
+ head = [[stemDx + dx, dy], [headDx + dx, dy],
38968
+ [dx, headLen + dy], [-headDx + dx, dy], [-stemDx + dx, dy]];
38969
+ coords = stem.concat(head);
38970
+ coords.push(stem[0].concat()); // closed path
38971
+ coords = [coords]; // Polygon coords
38972
+ }
38973
+
38974
+ if (d.anchor == 'end') {
38975
+ scaleAndShiftCoords(coords, 1, [-dx, -dy - headLen]);
38976
+ } else if (d.anchor == 'middle') {
38977
+ // shift midpoint away from the head a bit for a more balanced placement
38978
+ // scaleAndShiftCoords(coords, 1, [-dx/2, (-dy - headLen)/2]);
38979
+ scaleAndShiftCoords(coords, 1, [-dx * 0.5, -dy * 0.5 - headLen * 0.25]);
38980
+ }
38981
+
38982
+ rotateCoords(coords, direction);
38983
+ if (d.flipped) {
38984
+ flipY(coords);
38985
+ }
38986
+ return coords;
38987
+ }
38826
38988
 
38827
38989
  function calcStraightArrowCoords(stemLen, headLen, stemDx, headDx, baseDx) {
38828
38990
  return [[baseDx, 0], [stemDx, stemLen], [headDx, stemLen], [0, stemLen + headLen],
38829
38991
  [-headDx, stemLen], [-stemDx, stemLen], [-baseDx, 0], [baseDx, 0]];
38830
38992
  }
38831
38993
 
38832
- function calcArrowSize(d) {
38994
+ function calcArrowSize(d, stickArrow) {
38833
38995
  // don't display arrows with negative length
38834
38996
  var totalLen = Math.max(d.radius || d.length || d.r || 0, 0),
38835
38997
  scale = 1,
38836
38998
  o = initArrowSize(d); // calc several parameters
38837
38999
 
38838
- if (totalLen > 0) {
39000
+ if (totalLen >= 0) {
38839
39001
  scale = calcScale(totalLen, o.headLen, d);
38840
39002
  o.stemWidth *= scale;
38841
39003
  o.headWidth *= scale;
38842
39004
  o.headLen *= scale;
38843
- o.stemLen = totalLen - o.headLen;
39005
+ o.stemLen = stickArrow ? totalLen : totalLen - o.headLen;
38844
39006
  }
38845
39007
 
38846
39008
  if (o.headWidth < o.stemWidth) {
@@ -38861,7 +39023,7 @@ ${svg}
38861
39023
  } else if (stemLen + headLen > totalLen) {
38862
39024
  scale = totalLen / (stemLen + headLen);
38863
39025
  }
38864
- return scale;
39026
+ return scale || 0;
38865
39027
  }
38866
39028
 
38867
39029
  function initArrowSize(d) {
@@ -38891,56 +39053,6 @@ ${svg}
38891
39053
  return 1 / Math.tan(Math.PI * headAngle / 180 / 2) / 2;
38892
39054
  }
38893
39055
 
38894
- function getFilledArrowCoords(d) {
38895
- var direction = d.rotation || d.direction || 0,
38896
- stemTaper = d['stem-taper'] || 0,
38897
- curvature = d['stem-curve'] || 0,
38898
- size = calcArrowSize(d);
38899
- if (!size) return null;
38900
- var stemLen = size.stemLen,
38901
- headLen = size.headLen,
38902
- headDx = size.headWidth / 2,
38903
- stemDx = size.stemWidth / 2,
38904
- baseDx = stemDx * (1 - stemTaper),
38905
- head, stem, coords, dx, dy;
38906
-
38907
- if (curvature) {
38908
- if (direction > 0) curvature = -curvature;
38909
- var theta = Math.abs(curvature) / 180 * Math.PI;
38910
- var sign = curvature > 0 ? 1 : -1;
38911
- var ax = baseDx * Math.cos(theta); // rotate arrow base
38912
- var ay = baseDx * Math.sin(theta) * -sign;
38913
- dx = stemLen * Math.sin(theta / 2) * sign;
38914
- dy = stemLen * Math.cos(theta / 2);
38915
- var leftStem = getCurvedStemCoords(-ax, -ay, -stemDx + dx, dy, theta);
38916
- var rightStem = getCurvedStemCoords(ax, ay, stemDx + dx, dy, theta);
38917
- stem = leftStem.concat(rightStem.reverse());
38918
-
38919
- } else {
38920
- dx = 0;
38921
- dy = stemLen;
38922
- stem = [[-baseDx, 0], [baseDx, 0], [baseDx, 0]];
38923
- }
38924
-
38925
- // coordinates go counter clockwise, starting from the leftmost head coordinate
38926
- head = [[stemDx + dx, dy], [headDx + dx, dy],
38927
- [dx, headLen + dy], [-headDx + dx, dy], [-stemDx + dx, dy]];
38928
-
38929
- coords = stem.concat(head);
38930
- if (d.anchor == 'end') {
38931
- scaleAndShiftCoords(coords, 1, [-dx, -dy - headLen]);
38932
- } else if (d.anchor == 'middle') {
38933
- // shift midpoint away from the head a bit for a more balanced placement
38934
- // scaleAndShiftCoords(coords, 1, [-dx/2, (-dy - headLen)/2]);
38935
- scaleAndShiftCoords(coords, 1, [-dx * 0.5, -dy * 0.5 - headLen * 0.25]);
38936
- }
38937
-
38938
- rotateCoords(coords, direction);
38939
- if (d.flipped) {
38940
- flipY(coords);
38941
- }
38942
- return [coords];
38943
- }
38944
39056
 
38945
39057
  // ax, ay: point on the base
38946
39058
  // bx, by: point on the stem
@@ -38978,11 +39090,11 @@ ${svg}
38978
39090
 
38979
39091
  function makeCircleSymbol(d, opts) {
38980
39092
  var radius = getSymbolRadius(d);
38981
- // TODO: remove duplicatoin with svg-symbols.js
39093
+ // TODO: remove duplication with svg-symbols.js
38982
39094
  if (+opts.scale) radius *= +opts.scale;
38983
39095
  return {
38984
39096
  type: 'circle',
38985
- fill: getSymbolColor(d),
39097
+ fill: getSymbolFillColor(d),
38986
39098
  r: radius
38987
39099
  };
38988
39100
  }
@@ -39070,11 +39182,12 @@ ${svg}
39070
39182
  return 180 - centerAngle;
39071
39183
  }
39072
39184
 
39185
+ // Returns a svg-symbol object
39073
39186
  function makeRingSymbol(d, opts) {
39074
39187
  var scale = +opts.scale || 1;
39075
39188
  var radii = parseRings(d.radii || '2').map(function(r) { return r * scale; });
39076
39189
  var solidCenter = utils.isOdd(radii.length);
39077
- var color = getSymbolColor(d);
39190
+ var color = getSymbolFillColor(d);
39078
39191
  var parts = [];
39079
39192
  if (solidCenter) {
39080
39193
  parts.push({
@@ -39130,33 +39243,35 @@ ${svg}
39130
39243
  }
39131
39244
 
39132
39245
  // Returns an svg-symbol data object for one symbol
39133
- function makePolygonSymbol(coords, properties, geojsonType) {
39134
- if (geojsonType == 'MultiPolygon') {
39135
- coords = convertMultiPolygonCoords(coords);
39136
- } else if (geojsonType != 'Polygon') {
39246
+ function makePathSymbol(coords, properties, geojsonType) {
39247
+ var sym;
39248
+ if (geojsonType == 'MultiPolygon' || geojsonType == 'Polygon') {
39249
+ sym = {
39250
+ type: 'polygon',
39251
+ fill: getSymbolFillColor(properties),
39252
+ coordinates: geojsonType == 'Polygon' ? coords : flattenMultiPolygonCoords(coords)
39253
+ };
39254
+ } else if (geojsonType == 'LineString' || geojsonType == 'MultiLineString') {
39255
+ sym = {
39256
+ type: 'polyline',
39257
+ stroke: getSymbolStrokeColor(properties),
39258
+ 'stroke-width': properties['stroke-width'] || 2,
39259
+ coordinates: geojsonType == 'LineString' ? [coords] : coords
39260
+ };
39261
+ } else {
39137
39262
  error('Unsupported type:', geojsonType);
39138
39263
  }
39139
- roundCoordsForSVG(coords);
39140
- return {
39141
- type: 'polygon',
39142
- coordinates: coords,
39143
- fill: getSymbolColor(properties)
39144
- };
39145
- }
39146
-
39147
- function convertMultiPolygonCoords(coords) {
39148
- return coords.reduce(function(memo, poly) {
39149
- return memo.concat(poly);
39150
- }, []);
39264
+ roundCoordsForSVG(sym.coordinates);
39265
+ return sym;
39151
39266
  }
39152
39267
 
39153
39268
  // TODO: refactor to remove duplication in mapshaper-svg-style.js
39154
39269
  cmd.symbols = function(inputLyr, dataset, opts) {
39155
39270
  requireSinglePointLayer(inputLyr);
39156
39271
  var lyr = opts.no_replace ? copyLayer(inputLyr) : inputLyr;
39157
- var polygonMode = !!opts.polygons;
39272
+ var shapeMode = !!opts.geographic;
39158
39273
  var metersPerPx;
39159
- if (polygonMode) {
39274
+ if (shapeMode) {
39160
39275
  requireProjectedDataset(dataset);
39161
39276
  metersPerPx = opts.pixel_scale || getMetersPerPixel(lyr, dataset);
39162
39277
  }
@@ -39168,11 +39283,11 @@ ${svg}
39168
39283
  var rec = records[i] || {};
39169
39284
 
39170
39285
  // non-polygon symbols
39171
- if (!polygonMode && d.type == 'circle') {
39286
+ if (!shapeMode && d.type == 'circle') {
39172
39287
  rec['svg-symbol'] = makeCircleSymbol(d, opts);
39173
39288
  return;
39174
39289
  }
39175
- if (!polygonMode && d.type == 'ring') {
39290
+ if (!shapeMode && d.type == 'ring') {
39176
39291
  rec['svg-symbol'] = makeRingSymbol(d, opts);
39177
39292
  return;
39178
39293
  }
@@ -39180,7 +39295,10 @@ ${svg}
39180
39295
  var geojsonType = 'Polygon';
39181
39296
  var coords;
39182
39297
  // these symbols get converted to polygon shapes
39183
- if (d.type == 'arrow') {
39298
+ if (d.type == 'arrow' && opts.arrow_style == 'stick') {
39299
+ coords = getStickArrowCoords(d);
39300
+ geojsonType = 'MultiLineString';
39301
+ } else if (d.type == 'arrow') {
39184
39302
  coords = getFilledArrowCoords(d);
39185
39303
  } else if (d.type == 'ring') {
39186
39304
  coords = getRingCoords(d);
@@ -39192,23 +39310,24 @@ ${svg}
39192
39310
  }
39193
39311
  if (!coords) return null;
39194
39312
  rotateCoords(coords, +d.rotation || 0);
39195
- if (!polygonMode) {
39313
+ if (!shapeMode) {
39196
39314
  flipY(coords);
39197
39315
  }
39198
39316
  if (+opts.scale) {
39199
39317
  scaleAndShiftCoords(coords, +opts.scale, [0, 0]);
39200
39318
  }
39201
- if (polygonMode) {
39319
+ if (shapeMode) {
39202
39320
  scaleAndShiftCoords(coords, metersPerPx, shp[0]);
39203
- if (d.tfill) rec.fill = d.fill;
39321
+ if (d.fill) rec.fill = d.fill;
39322
+ if (d.stroke) rec.stroke = d.stroke;
39204
39323
  return createGeometry(coords, geojsonType);
39205
39324
  } else {
39206
- rec['svg-symbol'] = makePolygonSymbol(coords, d, geojsonType);
39325
+ rec['svg-symbol'] = makePathSymbol(coords, d, geojsonType);
39207
39326
  }
39208
39327
  });
39209
39328
 
39210
39329
  var outputLyr, dataset2;
39211
- if (polygonMode) {
39330
+ if (shapeMode) {
39212
39331
  dataset2 = importGeometries(geometries, records);
39213
39332
  outputLyr = mergeOutputLayerIntoDataset(inputLyr, dataset, dataset2, opts);
39214
39333
  outputLyr.data = lyr.data;
@@ -39242,9 +39361,10 @@ ${svg}
39242
39361
  }
39243
39362
 
39244
39363
  function getMetersPerPixel(lyr, dataset) {
39245
- // TODO: handle single point, no extent
39246
39364
  var bounds = getLayerBounds(lyr);
39247
- return bounds.width() / 800;
39365
+ // TODO: need a better way to handle a single point with no extent
39366
+ var extent = bounds.width() || bounds.height() || 1000;
39367
+ return extent / 800;
39248
39368
  }
39249
39369
 
39250
39370
  var Symbols = /*#__PURE__*/Object.freeze({
@@ -40818,6 +40938,7 @@ ${svg}
40818
40938
  FileTypes,
40819
40939
  FilterGeom,
40820
40940
  Frame,
40941
+ FrameData,
40821
40942
  Furniture,
40822
40943
  Geodesic,
40823
40944
  GeojsonExport,