mapshaper 0.6.120 → 0.7.0

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/www/mapshaper.js CHANGED
@@ -11723,7 +11723,11 @@
11723
11723
  version: 1,
11724
11724
  created: 'YYYY-MM-DDTHH:mm:ss.sssZ', // ISO string
11725
11725
  datasets: [],
11726
- gui: {} // see gui-session-snapshot-control.mjs
11726
+ gui: {}, // see gui-session-snapshot-control.mjs
11727
+ history: { // optional; only present in snapshots created by the GUI
11728
+ commands: ['-i foo.shp', '-simplify 10%', ...],
11729
+ savedAtIndex: 0 // index of the first command after the last save boundary
11730
+ }
11727
11731
  }
11728
11732
  */
11729
11733
 
@@ -11747,12 +11751,16 @@
11747
11751
  // exporting from command line: { compact: true, file: 'tmp.msx', final: true }
11748
11752
  // exporting from gui export menu: {compact: true, format: 'msx'}
11749
11753
  // saving gui temp snapshot: {compact: false}
11754
+ // opts.history: optional GUI session history captured by SessionHistory#getHistorySnapshot
11750
11755
  async function exportDatasetsToPack(datasets, opts) {
11751
11756
  var obj = {
11752
11757
  version: 1,
11753
11758
  created: (new Date).toISOString(),
11754
11759
  datasets: await Promise.all(datasets.map(dataset => exportDataset(dataset, opts)))
11755
11760
  };
11761
+ if (opts.history) {
11762
+ obj.history = opts.history;
11763
+ }
11756
11764
  return obj;
11757
11765
  }
11758
11766
 
@@ -11952,6 +11960,29 @@
11952
11960
  return file.endsWith('.' + PACKAGE_EXT);
11953
11961
  }
11954
11962
 
11963
+ // Returns true if @file has an extension that may identify a mapshaper
11964
+ // command file (e.g. "commands.txt"). Detection still requires a content
11965
+ // sniff via stringLooksLikeCommandFile().
11966
+ function isPotentialCommandFile(file) {
11967
+ var ext = getFileExtension(file || '').toLowerCase();
11968
+ return ext === 'txt';
11969
+ }
11970
+
11971
+ // True if @str looks like the content of a mapshaper command file: the first
11972
+ // non-blank, non-comment line begins with the magic word "mapshaper".
11973
+ function stringLooksLikeCommandFile(str) {
11974
+ str = String(str || '');
11975
+ // Skip a leading BOM
11976
+ if (str.charCodeAt(0) === 0xFEFF) str = str.slice(1);
11977
+ var lines = str.split(/\r?\n/);
11978
+ for (var i = 0; i < lines.length; i++) {
11979
+ var line = lines[i].trim();
11980
+ if (!line || line.charAt(0) === '#') continue;
11981
+ return /^mapshaper(\s|$)/.test(line);
11982
+ }
11983
+ return false;
11984
+ }
11985
+
11955
11986
  function isZipFile(file) {
11956
11987
  return /\.zip$/i.test(file);
11957
11988
  }
@@ -11996,10 +12027,12 @@
11996
12027
  isImportableAsBinary: isImportableAsBinary,
11997
12028
  isKmzFile: isKmzFile,
11998
12029
  isPackageFile: isPackageFile,
12030
+ isPotentialCommandFile: isPotentialCommandFile,
11999
12031
  isSupportedBinaryInputType: isSupportedBinaryInputType,
12000
12032
  isZipFile: isZipFile,
12001
12033
  looksLikeContentFile: looksLikeContentFile,
12002
12034
  looksLikeImportableFile: looksLikeImportableFile,
12035
+ stringLooksLikeCommandFile: stringLooksLikeCommandFile,
12003
12036
  stringLooksLikeJSON: stringLooksLikeJSON,
12004
12037
  stringLooksLikeKML: stringLooksLikeKML,
12005
12038
  stringLooksLikeSVG: stringLooksLikeSVG
@@ -13797,12 +13830,17 @@
13797
13830
  profileStart('intersectSegments');
13798
13831
  var raw = arcs.getVertexData(),
13799
13832
  intersections = [],
13833
+ // opts.limit (optional): stop searching once this many intersections have
13834
+ // been found. Used for cheap "is this dataset clean?" checks where we
13835
+ // only need a small sample.
13836
+ limit = opts.limit > 0 ? opts.limit : 0,
13800
13837
  arr;
13801
13838
  for (i=0; i<stripeCount; i++) {
13802
13839
  arr = intersectSegments(stripes[i], raw.xx, raw.yy, opts);
13803
13840
  for (j=0; j<arr.length; j++) {
13804
13841
  intersections.push(arr[j]);
13805
13842
  }
13843
+ if (limit > 0 && intersections.length >= limit) break;
13806
13844
  }
13807
13845
  profileEnd('intersectSegments');
13808
13846
  profileStart('dedupIntersections');
@@ -16098,7 +16136,7 @@
16098
16136
 
16099
16137
  // Get a copy of a layer containing a subset of the layer's features,
16100
16138
  // given a "where" expression in the options object
16101
- function getLayerSelection(lyr, arcs, opts) {
16139
+ function getLayerSelection$1(lyr, arcs, opts) {
16102
16140
  var lyr2 = utils.extend({}, lyr);
16103
16141
  var filterOpts = {
16104
16142
  expression: opts.where,
@@ -16115,11 +16153,11 @@
16115
16153
  if (!opts || !opts.where) {
16116
16154
  error('Missing required "where" parameter');
16117
16155
  }
16118
- var subsetLyr = getLayerSelection(lyr, arcs, opts);
16156
+ var subsetLyr = getLayerSelection$1(lyr, arcs, opts);
16119
16157
  var cmdOpts = utils.defaults({where: null}, opts); // prevent infinite recursion
16120
16158
  var outputLyr = commandFunc(subsetLyr, arcs, cmdOpts);
16121
16159
  var filterOpts = utils.defaults({invert: true}, opts);
16122
- var filteredLyr = getLayerSelection(lyr, arcs, filterOpts);
16160
+ var filteredLyr = getLayerSelection$1(lyr, arcs, filterOpts);
16123
16161
  var merged = cmd.mergeLayers([filteredLyr, outputLyr], {verbose: false, force: true});
16124
16162
  return merged[0];
16125
16163
  }
@@ -16148,7 +16186,7 @@
16148
16186
  result, compiled, defs, d;
16149
16187
  if (opts.where) {
16150
16188
  // TODO: implement no_replace option for filter() instead of this
16151
- lyr = getLayerSelection(lyr, arcs, opts);
16189
+ lyr = getLayerSelection$1(lyr, arcs, opts);
16152
16190
  msg += ' where ' + opts.where;
16153
16191
  }
16154
16192
  // Save any assigned variables to the defs object, so they will be available
@@ -16870,208 +16908,6 @@
16870
16908
  dissolvePolygonGeometry: dissolvePolygonGeometry
16871
16909
  });
16872
16910
 
16873
- // Generate a dissolved layer
16874
- // @opts.fields (optional) names of data fields (dissolves all if falsy)
16875
- // @opts.sum-fields (Array) (optional)
16876
- // @opts.copy-fields (Array) (optional)
16877
- //
16878
- cmd.dissolve = function(lyr, arcs, opts) {
16879
- var dissolveShapes, getGroupId;
16880
- opts = utils.extend({}, opts);
16881
- if (opts.where) {
16882
- return applyCommandToLayerSelection(cmd.dissolve, lyr, arcs, opts);
16883
- }
16884
- if (opts.field) opts.fields = [opts.field]; // support old "field" parameter
16885
- getGroupId = getCategoryClassifier(opts.fields, lyr.data);
16886
- if (opts.multipart || opts.group_points) {
16887
- dissolveShapes = makeMultipartShapes(lyr, getGroupId);
16888
- } else if (lyr.geometry_type == 'polygon') {
16889
- dissolveShapes = dissolvePolygonGeometry(lyr.shapes, getGroupId);
16890
- } else if (lyr.geometry_type == 'polyline') {
16891
- dissolveShapes = dissolvePolylineGeometry(lyr, getGroupId, arcs);
16892
- } else if (lyr.geometry_type == 'point') {
16893
- dissolveShapes = dissolvePointGeometry(lyr, getGroupId, opts);
16894
- }
16895
- return composeDissolveLayer(lyr, dissolveShapes, getGroupId, opts);
16896
- };
16897
-
16898
- function makeMultipartShapes(lyr, getGroupId) {
16899
- if (!lyr.shapes || !lyr.geometry_type) {
16900
- stop$1('Layer is missing geometry');
16901
- }
16902
- cloneShapes(lyr.shapes);
16903
- var shapes2 = [];
16904
- lyr.shapes.forEach(function(shp, i) {
16905
- var groupId = getGroupId(i);
16906
- if (!shp) return;
16907
- if (!shapes2[groupId]) {
16908
- shapes2[groupId] = shp;
16909
- } else {
16910
- shapes2[groupId].push.apply(shapes2[groupId], shp);
16911
- }
16912
- });
16913
- return shapes2;
16914
- }
16915
-
16916
- // @lyr: original undissolved layer
16917
- // @shapes: dissolved shapes
16918
- function composeDissolveLayer(lyr, shapes, getGroupId, opts) {
16919
- var records = null;
16920
- var lyr2;
16921
- if (lyr.data) {
16922
- records = aggregateDataRecords(lyr.data.getRecords(), getGroupId, opts);
16923
- // replace missing shapes with nulls
16924
- for (var i=0, n=records.length; i<n; i++) {
16925
- if (shapes && !shapes[i]) {
16926
- shapes[i] = null;
16927
- }
16928
- }
16929
- }
16930
- lyr2 = {
16931
- name: opts.no_replace ? null : lyr.name,
16932
- shapes: shapes,
16933
- data: records ? new DataTable(records) : null,
16934
- geometry_type: lyr.geometry_type
16935
- };
16936
- if (!opts.silent) {
16937
- printDissolveMessage(lyr, lyr2);
16938
- }
16939
- return lyr2;
16940
- }
16941
-
16942
- function printDissolveMessage(pre, post) {
16943
- var n1 = getFeatureCount(pre),
16944
- n2 = getFeatureCount(post),
16945
- msg = utils.format('Dissolved %,d feature%s into %,d feature%s',
16946
- n1, utils.pluralSuffix(n1), n2,
16947
- utils.pluralSuffix(n2));
16948
- message(msg);
16949
- }
16950
-
16951
- // Maps tile ids to shape ids (both are non-negative integers). Supports
16952
- // one-to-many mapping (a tile may belong to multiple shapes)
16953
- // Also maps shape ids to tile ids. A shape may contain multiple tiles
16954
- // Also supports 'flattening' -- removing one-to-many tile-shape mappings by
16955
- // removing all but one shape from a tile.
16956
- // Supports one-to-many mapping
16957
- function TileShapeIndex(mosaic, opts) {
16958
- // indexes for mapping tile ids to shape ids
16959
- var singleIndex = new Int32Array(mosaic.length);
16960
- utils.initializeArray(singleIndex, -1);
16961
- var multipleIndex = [];
16962
- // index that maps shape ids to tile ids
16963
- var shapeIndex = [];
16964
-
16965
- this.getTileIdsByShapeId = function(shapeId) {
16966
- var ids = shapeIndex[shapeId];
16967
- // need to filter out tile ids that have been set to -1 (indicating removal)
16968
- return ids ? ids.filter(function(id) {return id >= 0;}) : [];
16969
- };
16970
-
16971
- // assumes index has been flattened
16972
- this.getShapeIdByTileId = function(id) {
16973
- var shapeId = singleIndex[id];
16974
- return shapeId >= 0 ? shapeId : -1;
16975
- };
16976
-
16977
- // return ids of all shapes that include a tile
16978
- this.getShapeIdsByTileId = function(id) {
16979
- var singleId = singleIndex[id];
16980
- if (singleId >= 0) {
16981
- return [singleId];
16982
- }
16983
- if (singleId == -1) {
16984
- return [];
16985
- }
16986
- return multipleIndex[id];
16987
- };
16988
-
16989
- this.indexTileIdsByShapeId = function(shapeId, tileIds, weightFunction) {
16990
- shapeIndex[shapeId] = [];
16991
- for (var i=0; i<tileIds.length; i++) {
16992
- indexShapeIdByTileId(shapeId, tileIds[i], weightFunction);
16993
- }
16994
- };
16995
-
16996
- // remove many-to-one tile=>shape mappings
16997
- this.flatten = function() {
16998
- multipleIndex.forEach(function(shapeIds, tileId) {
16999
- flattenStackedTile(tileId);
17000
- });
17001
- multipleIndex = [];
17002
- };
17003
-
17004
- this.getUnusedTileIds = function() {
17005
- var ids = [];
17006
- for (var i=0, n=singleIndex.length; i<n; i++) {
17007
- if (singleIndex[i] == -1) ids.push(i);
17008
- }
17009
- return ids;
17010
- };
17011
-
17012
- // used by gap fill; assumes that flatten() has been called
17013
- this.addTileToShape = function(shapeId, tileId) {
17014
- if (shapeId in shapeIndex === false || singleIndex[tileId] != -1) {
17015
- error('Internal error');
17016
- }
17017
- singleIndex[tileId] = shapeId;
17018
- shapeIndex[shapeId].push(tileId);
17019
- };
17020
-
17021
- // add a shape id to a tile
17022
- function indexShapeIdByTileId(shapeId, tileId, weightFunction) {
17023
- var singleId = singleIndex[tileId];
17024
- if (singleId != -1 && opts.flat) {
17025
- // pick the best shape if we have a weight function
17026
- if (weightFunction && weightFunction(shapeId) > weightFunction(singleId)) {
17027
- // replace existing shape reference
17028
- removeTileFromShape(tileId, singleId); // bottleneck when overlaps are many
17029
- singleIndex[tileId] = singleId;
17030
- singleId = -1;
17031
- } else {
17032
- // keep existing shape reference
17033
- return;
17034
- }
17035
- }
17036
- if (singleId == -1) {
17037
- singleIndex[tileId] = shapeId;
17038
- } else if (singleId == -2) {
17039
- multipleIndex[tileId].push(shapeId);
17040
- } else {
17041
- multipleIndex[tileId] = [singleId, shapeId];
17042
- singleIndex[tileId] = -2;
17043
- }
17044
- shapeIndex[shapeId].push(tileId);
17045
- }
17046
-
17047
-
17048
- function flattenStackedTile(tileId) {
17049
- // TODO: select the best shape (using some metric)
17050
- var shapeIds = multipleIndex[tileId];
17051
- // if (!shapeIds || shapeIds.length > 1 === false) error('flattening error');
17052
- var selectedId = shapeIds[0];
17053
- var shapeId;
17054
- singleIndex[tileId] = selectedId; // add shape to single index
17055
- // remove tile from other stacked shapes
17056
- for (var i=0; i<shapeIds.length; i++) {
17057
- shapeId = shapeIds[i];
17058
- if (shapeId != selectedId) {
17059
- removeTileFromShape(tileId, shapeId);
17060
- }
17061
- }
17062
- }
17063
-
17064
- function removeTileFromShape(tileId, shapeId) {
17065
- var tileIds = shapeIndex[shapeId];
17066
- for (var i=0; i<tileIds.length; i++) {
17067
- if (tileIds[i] === tileId) {
17068
- tileIds[i] = -1;
17069
- break;
17070
- }
17071
- }
17072
- }
17073
- }
17074
-
17075
16911
  // Clean polygon or polyline shapes (in-place)
17076
16912
  //
17077
16913
  function cleanShapes(shapes, arcs, type) {
@@ -17688,6 +17524,336 @@
17688
17524
  sortCutPoints: sortCutPoints
17689
17525
  });
17690
17526
 
17527
+ // Options that require the topology-repair algorithm and are not supported
17528
+ // by the no-repair fast path.
17529
+ var REPAIR_REQUIRED_OPTS = ['gap_fill_area', 'sliver_control', 'allow_overlaps'];
17530
+
17531
+ // Sample size used to detect intersections in no-repair mode. Detection stops
17532
+ // after this many hits, so the warning message can include sample locations
17533
+ // without paying for an exhaustive scan on badly-formed input.
17534
+ var INTERSECTION_SAMPLE_LIMIT = 10;
17535
+
17536
+ // cmd.dissolve accepts two signatures:
17537
+ // (layers, dataset, opts) — multi-layer entry used by the CLI dispatcher.
17538
+ // Polygon layers go through the topology-repairing algorithm by default,
17539
+ // or through the legacy fast algorithm when opts.no_repair is set.
17540
+ // (lyr, arcs, opts) — legacy single-layer entry, retained for backward
17541
+ // compatibility with internal callers and existing tests. Always uses the
17542
+ // legacy fast algorithm; does not perform topology repair.
17543
+ //
17544
+ cmd.dissolve = function(arg1, arg2, opts) {
17545
+ if (Array.isArray(arg1)) {
17546
+ return dissolveLayers(arg1, arg2, opts);
17547
+ }
17548
+ return dissolveSingleLayer(arg1, arg2, opts);
17549
+ };
17550
+
17551
+ function dissolveLayers(layers, dataset, optsArg) {
17552
+ var opts = utils.extend({}, optsArg);
17553
+ if (opts.field) opts.fields = [opts.field]; // support old "field" parameter
17554
+
17555
+ if (opts.no_repair) {
17556
+ var conflicting = REPAIR_REQUIRED_OPTS.filter(function(k) { return opts[k]; });
17557
+ if (conflicting.length > 0) {
17558
+ stop$1('The no-repair option is incompatible with',
17559
+ conflicting.map(function(k) { return k.replace(/_/g, '-'); }).join(', '));
17560
+ }
17561
+ }
17562
+
17563
+ var anyPolygon = layers.some(function(lyr) {
17564
+ return lyr.geometry_type == 'polygon' && layerHasPaths(lyr);
17565
+ });
17566
+
17567
+ if (anyPolygon) {
17568
+ if (opts.no_repair) {
17569
+ detectAndWarnIntersections(dataset, opts);
17570
+ } else {
17571
+ addIntersectionCuts(dataset, opts);
17572
+ }
17573
+ }
17574
+
17575
+ return layers.map(function(lyr) {
17576
+ return dissolveOneLayer(lyr, dataset, opts);
17577
+ });
17578
+ }
17579
+
17580
+ function dissolveOneLayer(lyr, dataset, opts) {
17581
+ if (opts.where) {
17582
+ return dissolveLayerWithWhereClause(lyr, dataset, opts);
17583
+ }
17584
+ if (opts.multipart || opts.group_points) {
17585
+ var classifier = getCategoryClassifier(opts.fields, lyr.data);
17586
+ return composeDissolveLayer(lyr, makeMultipartShapes(lyr, classifier), classifier, opts);
17587
+ }
17588
+ if (lyr.geometry_type == 'polygon') {
17589
+ return dissolvePolygonInLayer(lyr, dataset, opts);
17590
+ }
17591
+ if (lyr.geometry_type == 'polyline') {
17592
+ var polylineClassifier = getCategoryClassifier(opts.fields, lyr.data);
17593
+ var polylineShapes = dissolvePolylineGeometry(lyr, polylineClassifier, dataset.arcs);
17594
+ return composeDissolveLayer(lyr, polylineShapes, polylineClassifier, opts);
17595
+ }
17596
+ if (lyr.geometry_type == 'point') {
17597
+ var pointClassifier = getCategoryClassifier(opts.fields, lyr.data);
17598
+ var pointShapes = dissolvePointGeometry(lyr, pointClassifier, opts);
17599
+ return composeDissolveLayer(lyr, pointShapes, pointClassifier, opts);
17600
+ }
17601
+ // tabular (no geometry): aggregate records only
17602
+ var nullClassifier = getCategoryClassifier(opts.fields, lyr.data);
17603
+ return composeDissolveLayer(lyr, undefined, nullClassifier, opts);
17604
+ }
17605
+
17606
+ function dissolvePolygonInLayer(lyr, dataset, opts) {
17607
+ if (!layerHasPaths(lyr)) return lyr;
17608
+ if (opts.no_repair) {
17609
+ var classifier = getCategoryClassifier(opts.fields, lyr.data);
17610
+ var shapes = dissolvePolygonGeometry(lyr.shapes, classifier);
17611
+ return composeDissolveLayer(lyr, shapes, classifier, opts);
17612
+ }
17613
+ return dissolvePolygonLayer2(lyr, dataset, opts);
17614
+ }
17615
+
17616
+ function dissolveLayerWithWhereClause(lyr, dataset, opts) {
17617
+ // Run dissolve on a subset of features defined by opts.where, then merge the
17618
+ // dissolved subset back together with the unselected features.
17619
+ // Topology repair (if needed) was already performed at the dataset level by
17620
+ // dissolveLayers, so the recursive call uses no_repair=true to avoid doing
17621
+ // the work a second time on a subset of the same arcs.
17622
+ var arcs = dataset.arcs;
17623
+ var subsetLyr = getLayerSelection(lyr, arcs, opts);
17624
+ var cmdOpts = utils.defaults({where: null, no_repair: true}, opts);
17625
+ var dissolved = dissolveOneLayer(subsetLyr, dataset, cmdOpts);
17626
+ var filteredLyr = getLayerSelection(lyr, arcs, utils.defaults({invert: true}, opts));
17627
+ var merged = cmd.mergeLayers([filteredLyr, dissolved], {verbose: false, force: true});
17628
+ return merged[0];
17629
+ }
17630
+
17631
+ function getLayerSelection(lyr, arcs, opts) {
17632
+ var lyr2 = utils.extend({}, lyr);
17633
+ var filterOpts = {
17634
+ expression: opts.where,
17635
+ invert: !!opts.invert,
17636
+ verbose: false,
17637
+ no_replace: opts.no_replace
17638
+ };
17639
+ return cmd.filterFeatures(lyr2, arcs, filterOpts);
17640
+ }
17641
+
17642
+ // Detect a small sample of segment intersections; print a warning if any are
17643
+ // found. Used by the no-repair fast path to alert users that their input has
17644
+ // topology problems. Detection stops after INTERSECTION_SAMPLE_LIMIT hits, so
17645
+ // the cost is bounded for badly-formed input.
17646
+ function detectAndWarnIntersections(dataset, opts) {
17647
+ if (opts.quiet || opts.silent) return;
17648
+ if (!dataset.arcs || dataset.arcs.size() === 0) return;
17649
+ var sample = findSegmentIntersections(dataset.arcs, {limit: INTERSECTION_SAMPLE_LIMIT});
17650
+ if (sample.length === 0) return;
17651
+ var atLeast = sample.length >= INTERSECTION_SAMPLE_LIMIT ? 'at least ' : '';
17652
+ message('Warning: found ' + atLeast + sample.length +
17653
+ ' segment intersection' + (sample.length == 1 ? '' : 's') +
17654
+ '. The no-repair option assumes clean topology; output may be incorrect.');
17655
+ }
17656
+
17657
+ // Backward-compat: the legacy per-layer entry, still used by internal callers
17658
+ // and by tests that exercise the original fast algorithm directly. Retains
17659
+ // the original behavior (no topology repair, no multi-layer prep).
17660
+ function dissolveSingleLayer(lyr, arcs, opts) {
17661
+ var dissolveShapes, classifier;
17662
+ opts = utils.extend({}, opts);
17663
+ if (opts.where) {
17664
+ return applyCommandToLayerSelection(dissolveSingleLayer, lyr, arcs, opts);
17665
+ }
17666
+ if (opts.field) opts.fields = [opts.field];
17667
+ classifier = getCategoryClassifier(opts.fields, lyr.data);
17668
+ if (opts.multipart || opts.group_points) {
17669
+ dissolveShapes = makeMultipartShapes(lyr, classifier);
17670
+ } else if (lyr.geometry_type == 'polygon') {
17671
+ dissolveShapes = dissolvePolygonGeometry(lyr.shapes, classifier);
17672
+ } else if (lyr.geometry_type == 'polyline') {
17673
+ dissolveShapes = dissolvePolylineGeometry(lyr, classifier, arcs);
17674
+ } else if (lyr.geometry_type == 'point') {
17675
+ dissolveShapes = dissolvePointGeometry(lyr, classifier, opts);
17676
+ }
17677
+ return composeDissolveLayer(lyr, dissolveShapes, classifier, opts);
17678
+ }
17679
+
17680
+ function makeMultipartShapes(lyr, getGroupId) {
17681
+ if (!lyr.shapes || !lyr.geometry_type) {
17682
+ stop$1('Layer is missing geometry');
17683
+ }
17684
+ cloneShapes(lyr.shapes);
17685
+ var shapes2 = [];
17686
+ lyr.shapes.forEach(function(shp, i) {
17687
+ var groupId = getGroupId(i);
17688
+ if (!shp) return;
17689
+ if (!shapes2[groupId]) {
17690
+ shapes2[groupId] = shp;
17691
+ } else {
17692
+ shapes2[groupId].push.apply(shapes2[groupId], shp);
17693
+ }
17694
+ });
17695
+ return shapes2;
17696
+ }
17697
+
17698
+ // @lyr: original undissolved layer
17699
+ // @shapes: dissolved shapes
17700
+ function composeDissolveLayer(lyr, shapes, getGroupId, opts) {
17701
+ var records = null;
17702
+ var lyr2;
17703
+ if (lyr.data) {
17704
+ records = aggregateDataRecords(lyr.data.getRecords(), getGroupId, opts);
17705
+ // replace missing shapes with nulls
17706
+ for (var i=0, n=records.length; i<n; i++) {
17707
+ if (shapes && !shapes[i]) {
17708
+ shapes[i] = null;
17709
+ }
17710
+ }
17711
+ }
17712
+ lyr2 = {
17713
+ name: opts.no_replace ? null : lyr.name,
17714
+ shapes: shapes,
17715
+ data: records ? new DataTable(records) : null,
17716
+ geometry_type: lyr.geometry_type
17717
+ };
17718
+ if (!opts.silent) {
17719
+ printDissolveMessage(lyr, lyr2);
17720
+ }
17721
+ return lyr2;
17722
+ }
17723
+
17724
+ function printDissolveMessage(pre, post) {
17725
+ var n1 = getFeatureCount(pre),
17726
+ n2 = getFeatureCount(post),
17727
+ msg = utils.format('Dissolved %,d feature%s into %,d feature%s',
17728
+ n1, utils.pluralSuffix(n1), n2,
17729
+ utils.pluralSuffix(n2));
17730
+ message(msg);
17731
+ }
17732
+
17733
+ // Maps tile ids to shape ids (both are non-negative integers). Supports
17734
+ // one-to-many mapping (a tile may belong to multiple shapes)
17735
+ // Also maps shape ids to tile ids. A shape may contain multiple tiles
17736
+ // Also supports 'flattening' -- removing one-to-many tile-shape mappings by
17737
+ // removing all but one shape from a tile.
17738
+ // Supports one-to-many mapping
17739
+ function TileShapeIndex(mosaic, opts) {
17740
+ // indexes for mapping tile ids to shape ids
17741
+ var singleIndex = new Int32Array(mosaic.length);
17742
+ utils.initializeArray(singleIndex, -1);
17743
+ var multipleIndex = [];
17744
+ // index that maps shape ids to tile ids
17745
+ var shapeIndex = [];
17746
+
17747
+ this.getTileIdsByShapeId = function(shapeId) {
17748
+ var ids = shapeIndex[shapeId];
17749
+ // need to filter out tile ids that have been set to -1 (indicating removal)
17750
+ return ids ? ids.filter(function(id) {return id >= 0;}) : [];
17751
+ };
17752
+
17753
+ // assumes index has been flattened
17754
+ this.getShapeIdByTileId = function(id) {
17755
+ var shapeId = singleIndex[id];
17756
+ return shapeId >= 0 ? shapeId : -1;
17757
+ };
17758
+
17759
+ // return ids of all shapes that include a tile
17760
+ this.getShapeIdsByTileId = function(id) {
17761
+ var singleId = singleIndex[id];
17762
+ if (singleId >= 0) {
17763
+ return [singleId];
17764
+ }
17765
+ if (singleId == -1) {
17766
+ return [];
17767
+ }
17768
+ return multipleIndex[id];
17769
+ };
17770
+
17771
+ this.indexTileIdsByShapeId = function(shapeId, tileIds, weightFunction) {
17772
+ shapeIndex[shapeId] = [];
17773
+ for (var i=0; i<tileIds.length; i++) {
17774
+ indexShapeIdByTileId(shapeId, tileIds[i], weightFunction);
17775
+ }
17776
+ };
17777
+
17778
+ // remove many-to-one tile=>shape mappings
17779
+ this.flatten = function() {
17780
+ multipleIndex.forEach(function(shapeIds, tileId) {
17781
+ flattenStackedTile(tileId);
17782
+ });
17783
+ multipleIndex = [];
17784
+ };
17785
+
17786
+ this.getUnusedTileIds = function() {
17787
+ var ids = [];
17788
+ for (var i=0, n=singleIndex.length; i<n; i++) {
17789
+ if (singleIndex[i] == -1) ids.push(i);
17790
+ }
17791
+ return ids;
17792
+ };
17793
+
17794
+ // used by gap fill; assumes that flatten() has been called
17795
+ this.addTileToShape = function(shapeId, tileId) {
17796
+ if (shapeId in shapeIndex === false || singleIndex[tileId] != -1) {
17797
+ error('Internal error');
17798
+ }
17799
+ singleIndex[tileId] = shapeId;
17800
+ shapeIndex[shapeId].push(tileId);
17801
+ };
17802
+
17803
+ // add a shape id to a tile
17804
+ function indexShapeIdByTileId(shapeId, tileId, weightFunction) {
17805
+ var singleId = singleIndex[tileId];
17806
+ if (singleId != -1 && opts.flat) {
17807
+ // pick the best shape if we have a weight function
17808
+ if (weightFunction && weightFunction(shapeId) > weightFunction(singleId)) {
17809
+ // replace existing shape reference
17810
+ removeTileFromShape(tileId, singleId); // bottleneck when overlaps are many
17811
+ singleIndex[tileId] = singleId;
17812
+ singleId = -1;
17813
+ } else {
17814
+ // keep existing shape reference
17815
+ return;
17816
+ }
17817
+ }
17818
+ if (singleId == -1) {
17819
+ singleIndex[tileId] = shapeId;
17820
+ } else if (singleId == -2) {
17821
+ multipleIndex[tileId].push(shapeId);
17822
+ } else {
17823
+ multipleIndex[tileId] = [singleId, shapeId];
17824
+ singleIndex[tileId] = -2;
17825
+ }
17826
+ shapeIndex[shapeId].push(tileId);
17827
+ }
17828
+
17829
+
17830
+ function flattenStackedTile(tileId) {
17831
+ // TODO: select the best shape (using some metric)
17832
+ var shapeIds = multipleIndex[tileId];
17833
+ // if (!shapeIds || shapeIds.length > 1 === false) error('flattening error');
17834
+ var selectedId = shapeIds[0];
17835
+ var shapeId;
17836
+ singleIndex[tileId] = selectedId; // add shape to single index
17837
+ // remove tile from other stacked shapes
17838
+ for (var i=0; i<shapeIds.length; i++) {
17839
+ shapeId = shapeIds[i];
17840
+ if (shapeId != selectedId) {
17841
+ removeTileFromShape(tileId, shapeId);
17842
+ }
17843
+ }
17844
+ }
17845
+
17846
+ function removeTileFromShape(tileId, shapeId) {
17847
+ var tileIds = shapeIndex[shapeId];
17848
+ for (var i=0; i<tileIds.length; i++) {
17849
+ if (tileIds[i] === tileId) {
17850
+ tileIds[i] = -1;
17851
+ break;
17852
+ }
17853
+ }
17854
+ }
17855
+ }
17856
+
17691
17857
  // Support for timing using T.start() and T.stop()
17692
17858
  var T$1 = {
17693
17859
  stack: [],
@@ -26866,26 +27032,71 @@ ${svg}
26866
27032
  // show help if only a command name is given
26867
27033
  argv.unshift('-help'); // kludge (assumes -help <command> syntax)
26868
27034
  } else if (argv.length > 0 && !tokenLooksLikeCommand(argv[0]) && _default) {
26869
- // if there are arguments before the first explicit command, use the default command
26870
- argv.unshift('-' + _default);
27035
+ // if there are arguments before the first explicit command, use the default
27036
+ // command. If _default is a function, let it inspect/mutate argv directly
27037
+ // (this lets callers route by file type, e.g. .txt -> -run).
27038
+ if (typeof _default == 'function') {
27039
+ _default(argv);
27040
+ } else {
27041
+ argv.unshift('-' + _default);
27042
+ }
26871
27043
  }
26872
27044
 
27045
+ // snapshot the argv so we can record the source tokens consumed by each
27046
+ // command (used by the late-binding {{...}} interpolator)
27047
+ var argvSnapshot = argv.slice();
27048
+ var totalLen = argvSnapshot.length;
27049
+
26873
27050
  while (argv.length > 0) {
27051
+ var consumedBefore = totalLen - argv.length;
26874
27052
  cmdName = readCommandName(argv);
26875
27053
  if (!cmdName) {
26876
27054
  stop$1("Invalid command:", argv[0]);
26877
27055
  }
26878
27056
  cmdDef = findCommandDefn(cmdName, commandDefs) || null;
27057
+ // Look ahead at the option tokens this command will consume. If any
27058
+ // contains a {{...}} placeholder, the late-binding interpolator (in
27059
+ // mapshaper-run-commands.mjs) will re-parse this command after
27060
+ // substitution, so we skip validation here to avoid premature errors
27061
+ // on un-interpolated tokens.
27062
+ var lookaheadTokens = peekCommandTokens(argv);
27063
+ var deferValidate = lookaheadTokens.some(tokenContainsPlaceholder);
26879
27064
  if (!cmdDef) {
26880
27065
  cmd = parseUnknownCommandOptions(argv, cmdName);
26881
27066
  } else {
26882
- cmd = parseCommandOptions(argv, cmdDef);
26883
- }
27067
+ cmd = parseCommandOptions(argv, cmdDef, deferValidate);
27068
+ }
27069
+ var consumedAfter = totalLen - argv.length;
27070
+ // Stash the source tokens for the late-binding {{...}} interpolator.
27071
+ // Defined as non-enumerable so existing tests that deep-equal parsed
27072
+ // commands aren't affected.
27073
+ Object.defineProperty(cmd, '_tokens', {
27074
+ value: argvSnapshot.slice(consumedBefore, consumedAfter),
27075
+ enumerable: false,
27076
+ writable: true,
27077
+ configurable: true
27078
+ });
26884
27079
  commands.push(cmd);
26885
27080
  }
26886
27081
  return commands;
26887
27082
 
26888
- function parseCommandOptions(argv, cmdDef) {
27083
+ // Return the tokens (without removing them) that the next command would
27084
+ // consume. Boundary is the next command name, matching the rule in
27085
+ // parseCommandOptions.
27086
+ function peekCommandTokens(argv) {
27087
+ var out = [];
27088
+ for (var i = 0; i < argv.length; i++) {
27089
+ if (tokenLooksLikeCommand(argv[i])) break;
27090
+ out.push(argv[i]);
27091
+ }
27092
+ return out;
27093
+ }
27094
+
27095
+ function tokenContainsPlaceholder(s) {
27096
+ return typeof s == 'string' && s.indexOf('{{') !== -1;
27097
+ }
27098
+
27099
+ function parseCommandOptions(argv, cmdDef, deferValidate) {
26889
27100
  var cmd = {
26890
27101
  name: cmdDef.name,
26891
27102
  options: {},
@@ -26897,10 +27108,10 @@ ${svg}
26897
27108
  }
26898
27109
 
26899
27110
  try {
26900
- if (cmd._.length > 0) {
27111
+ if (cmd._.length > 0 && !deferValidate) {
26901
27112
  readDefaultOptionValue(cmd, cmdDef);
26902
27113
  }
26903
- if (cmdDef.validate) {
27114
+ if (cmdDef.validate && !deferValidate) {
26904
27115
  cmdDef.validate(cmd);
26905
27116
  }
26906
27117
  delete cmd.options._; // kludge to remove -o placeholder option
@@ -27377,7 +27588,16 @@ ${svg}
27377
27588
 
27378
27589
  parser.section('I/O commands');
27379
27590
 
27380
- parser.default('i');
27591
+ // When the command line begins with a non-command argument, route .txt files
27592
+ // to "-run <path>" (command files) and everything else to "-i <path>" (data
27593
+ // files). The actual file-or-expression check happens inside cmd.run.
27594
+ parser.default(function(argv) {
27595
+ if (isPotentialCommandFile(argv[0])) {
27596
+ argv.unshift('-run');
27597
+ } else {
27598
+ argv.unshift('-i');
27599
+ }
27600
+ });
27381
27601
 
27382
27602
  parser.command('i')
27383
27603
  .describe('input one or more files')
@@ -27394,6 +27614,10 @@ ${svg}
27394
27614
  describe: 'import files to separate layers with shared topology',
27395
27615
  type: 'flag'
27396
27616
  })
27617
+ .option('batch-mode', {
27618
+ describe: 'apply subsequent commands separately to each input file',
27619
+ type: 'flag'
27620
+ })
27397
27621
  .option('merge-files', {
27398
27622
  // describe: 'merge features from compatible files into the same layer',
27399
27623
  type: 'flag'
@@ -28025,7 +28249,7 @@ ${svg}
28025
28249
  });
28026
28250
 
28027
28251
  parser.command('dissolve')
28028
- .describe('merge features within a layer')
28252
+ .describe('merge features within a layer (repairs polygon topology)')
28029
28253
  .example('Dissolve all polygons in a feature layer into a single polygon\n' +
28030
28254
  '$ mapshaper states.shp -dissolve -o country.shp')
28031
28255
  .example('Generate state-level polygons by dissolving a layer of counties\n' +
@@ -28052,19 +28276,33 @@ ${svg}
28052
28276
  type: 'flag',
28053
28277
  describe: '[points] use 2D math to find centroids of latlong points'
28054
28278
  })
28279
+ .option('gap-fill-area', {
28280
+ describe: '[polygons] threshold for filling gaps, e.g. 1.5km2',
28281
+ type: 'area'
28282
+ })
28283
+ .option('sliver-control', sliverControlOpt)
28284
+ .option('allow-overlaps', {
28285
+ describe: '[polygons] allow output polygons to overlap (disables gap fill)',
28286
+ type: 'flag'
28287
+ })
28288
+ .option('no-repair', {
28289
+ describe: '[polygons] skip topology repair (faster; assumes clean input)',
28290
+ type: 'flag'
28291
+ })
28292
+ .option('snap-interval', snapIntervalOpt)
28293
+ .option('no-snap', noSnapOpt)
28055
28294
  .option('name', nameOpt)
28056
28295
  .option('target', targetOpt)
28057
28296
  .option('no-replace', noReplaceOpt);
28058
28297
 
28059
28298
 
28299
+ // -dissolve2 is now an alias for -dissolve (the topology repair behavior of
28300
+ // -dissolve2 is now the default behavior of -dissolve). Kept for backward
28301
+ // compatibility; prints a deprecation notice when used.
28060
28302
  parser.command('dissolve2')
28061
- .describe('merge adjacent polygons (repairs overlaps and gaps)')
28303
+ .describe('alias for -dissolve (deprecated)')
28062
28304
  .option('field', {}) // old arg handled by dissolve function
28063
28305
  .option('fields', dissolveFieldsOpt)
28064
- // UPDATE: Use -mosaic command for debugging
28065
- //.option('mosaic', {type: 'flag'}) // debugging option
28066
- //.option('arcs', {type: 'flag'}) // debugging option
28067
- //.option('tiles', {type: 'flag'}) // debugging option
28068
28306
  .option('calc', calcOpt)
28069
28307
  .option('sum-fields', sumFieldsOpt)
28070
28308
  .option('copy-fields', copyFieldsOpt)
@@ -29344,10 +29582,11 @@ ${svg}
29344
29582
  });
29345
29583
 
29346
29584
  parser.command('run')
29347
- .describe('create commands on-the-fly and run them')
29585
+ .describe('run commands from a command file or JS expression')
29348
29586
  .option('expression', {
29349
29587
  DEFAULT: true,
29350
- describe: 'JS expression or template to generate command(s)'
29588
+ label: '<file|expression>',
29589
+ describe: 'path to a .txt command file, or a JS expression/template'
29351
29590
  })
29352
29591
  // deprecated
29353
29592
  .option('commands', {alias_to: 'expression'})
@@ -29519,6 +29758,14 @@ ${svg}
29519
29758
  }
29520
29759
  });
29521
29760
 
29761
+ parser.command('defaults')
29762
+ .describe('set {{VAR}} interpolation variables only if not already set')
29763
+ .option('values', {
29764
+ DEFAULT: {
29765
+ multi_arg: true
29766
+ }
29767
+ });
29768
+
29522
29769
  parser.command('encodings')
29523
29770
  .describe('print list of supported text encodings (for .dbf import)');
29524
29771
 
@@ -29563,6 +29810,14 @@ ${svg}
29563
29810
  parser.command('quiet')
29564
29811
  .describe('inhibit console messages');
29565
29812
 
29813
+ parser.command('vars')
29814
+ .describe('define variables for {{VAR}} interpolation (overwrites)')
29815
+ .option('values', {
29816
+ DEFAULT: {
29817
+ multi_arg: true
29818
+ }
29819
+ });
29820
+
29566
29821
  parser.command('verbose')
29567
29822
  .describe('print verbose processing messages');
29568
29823
 
@@ -41733,16 +41988,207 @@ ${svg}
41733
41988
  compiled(null, defs);
41734
41989
  };
41735
41990
 
41736
- // Removes small gaps and all overlaps
41737
- cmd.dissolve2 = function(layers, dataset, opts) {
41738
- layers.forEach(requirePolygonLayer);
41739
- addIntersectionCuts(dataset, opts);
41740
- return layers.map(function(lyr) {
41741
- if (!layerHasPaths(lyr)) return lyr;
41742
- return dissolvePolygonLayer2(lyr, dataset, opts);
41991
+ // Variable name pattern. Matches simple identifiers: must start with a letter
41992
+ // or underscore, followed by letters, digits or underscores.
41993
+ var VAR_NAME_RXP = /^[A-Za-z_][A-Za-z0-9_]*$/;
41994
+
41995
+ // Pattern that matches a {{...}} placeholder in command text. The optional
41996
+ // leading character is a backslash (escape) which keeps the placeholder
41997
+ // literal. The braces themselves cannot appear inside a placeholder.
41998
+ //
41999
+ // Group 1: the leading escape (if present)
42000
+ // Group 2: the contents between {{ and }}
42001
+ //
42002
+ var PLACEHOLDER_RXP = /(\\?)\{\{([^{}]+?)\}\}/g;
42003
+
42004
+ // Pattern matching a "KEY=value" inline -vars argument.
42005
+ var ASSIGNMENT_RXP = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
42006
+
42007
+ // Returns true if @s is a valid mapshaper variable name.
42008
+ function isValidVarName(s) {
42009
+ return typeof s == 'string' && VAR_NAME_RXP.test(s);
42010
+ }
42011
+
42012
+ // Returns true if @str might contain a {{...}} placeholder. This is a cheap
42013
+ // fast-path test used to skip re-parsing for commands that have no
42014
+ // placeholders. False positives (e.g. literal "{{" inside a quoted JS
42015
+ // expression) just trigger an interpolation pass that finds nothing to do.
42016
+ function containsPlaceholder(str) {
42017
+ return typeof str == 'string' && str.indexOf('{{') !== -1;
42018
+ }
42019
+
42020
+ // Validate the parsed contents of a -vars JSON file. The file must contain a
42021
+ // flat object whose values are primitive (string, number, boolean, null).
42022
+ // Throws on invalid input. Returns the same object.
42023
+ function validateVarsObject(obj, source) {
42024
+ source = source || '<vars>';
42025
+ if (!utils.isObject(obj) || Array.isArray(obj)) {
42026
+ stop$1('Invalid vars file (' + source + '): expected an object at the top level');
42027
+ }
42028
+ Object.keys(obj).forEach(function(key) {
42029
+ if (!isValidVarName(key)) {
42030
+ stop$1('Invalid var name in ' + source + ': ' + JSON.stringify(key));
42031
+ }
42032
+ var v = obj[key];
42033
+ if (v !== null && typeof v != 'string' && typeof v != 'number' &&
42034
+ typeof v != 'boolean') {
42035
+ stop$1('Invalid value for var "' + key + '" in ' + source +
42036
+ ': expected a string, number, boolean or null');
42037
+ }
42038
+ });
42039
+ return obj;
42040
+ }
42041
+
42042
+ // Resolve a single -vars argument. Each argument is either:
42043
+ // * an inline assignment "KEY=value" (key must be a valid var name)
42044
+ // * a path to a JSON file containing a flat object of vars
42045
+ //
42046
+ // @arg: the raw argument string
42047
+ // @cache: optional input cache (passed to cli.readFile so files dropped into
42048
+ // the GUI can be resolved by name)
42049
+ // @merge: target object that receives the resolved entries
42050
+ //
42051
+ function resolveVarsArg(arg, cache, merge) {
42052
+ var assignment = ASSIGNMENT_RXP.exec(arg);
42053
+ if (assignment) {
42054
+ merge[assignment[1]] = assignment[2];
42055
+ return;
42056
+ }
42057
+ // Treat as a JSON file path
42058
+ cli.checkFileExists(arg, cache);
42059
+ var content = cli.readFile(arg, 'utf8', cache);
42060
+ var obj;
42061
+ try {
42062
+ obj = JSON.parse(content);
42063
+ } catch(e) {
42064
+ stop$1('Failed to parse vars file (' + arg + '): ' + e.message);
42065
+ }
42066
+ validateVarsObject(obj, arg);
42067
+ Object.keys(obj).forEach(function(key) {
42068
+ merge[key] = obj[key];
42069
+ });
42070
+ }
42071
+
42072
+ // Resolve an array of -vars arguments into a flat scope object. Later
42073
+ // arguments override earlier ones.
42074
+ function parseVarsArgs(args, cache) {
42075
+ var scope = {};
42076
+ if (!Array.isArray(args)) return scope;
42077
+ args.forEach(function(arg) {
42078
+ resolveVarsArg(arg, cache, scope);
42079
+ });
42080
+ return scope;
42081
+ }
42082
+
42083
+ // Look up env.* variables. Throws in environments without process.env.
42084
+ function lookupEnvVar(name) {
42085
+ if (typeof process == 'undefined' || !process.env) {
42086
+ stop$1('Environment variables are not available in this context');
42087
+ }
42088
+ return process.env[name];
42089
+ }
42090
+
42091
+ // Resolve a single placeholder expression to a string. Recognised forms:
42092
+ // VAR -> defs[VAR]
42093
+ // env.VAR -> process.env[VAR]
42094
+ //
42095
+ // Throws on undefined names, invalid syntax, or non-primitive values.
42096
+ //
42097
+ function resolvePlaceholder(expr, defs) {
42098
+ expr = expr.trim();
42099
+ var envMatch = /^env\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
42100
+ var val;
42101
+ if (envMatch) {
42102
+ val = lookupEnvVar(envMatch[1]);
42103
+ if (val === undefined || val === null) {
42104
+ stop$1('Undefined environment variable: ' + envMatch[1]);
42105
+ }
42106
+ return String(val);
42107
+ }
42108
+ if (!isValidVarName(expr)) {
42109
+ stop$1('Invalid variable reference: {{' + expr + '}}');
42110
+ }
42111
+ if (!defs || !(expr in defs)) {
42112
+ stop$1('Undefined variable: ' + expr);
42113
+ }
42114
+ val = defs[expr];
42115
+ if (val === null || val === undefined) {
42116
+ stop$1('Undefined variable: ' + expr);
42117
+ }
42118
+ if (typeof val != 'string' && typeof val != 'number' &&
42119
+ typeof val != 'boolean') {
42120
+ stop$1('Variable {{' + expr + '}} is not a primitive value (got ' +
42121
+ (typeof val) + ')');
42122
+ }
42123
+ return String(val);
42124
+ }
42125
+
42126
+ // Substitute {{...}} placeholders in @str using @defs. Placeholders that
42127
+ // are preceded by a backslash are left literal (with the backslash removed).
42128
+ // Substitution is single-pass (no recursion) so that values containing
42129
+ // "{{...}}" do not trigger further interpolation.
42130
+ //
42131
+ function interpolateString(str, defs) {
42132
+ if (typeof str != 'string') return str;
42133
+ return str.replace(PLACEHOLDER_RXP, function(match, escape, expr) {
42134
+ if (escape === '\\') return '{{' + expr + '}}';
42135
+ return resolvePlaceholder(expr, defs);
42136
+ });
42137
+ }
42138
+
42139
+ // -vars KEY=value [KEY=value ...] inline assignments
42140
+ // -vars file.json [more ...] load primitives from a flat JSON object
42141
+ // Mixed forms allowed; later args override earlier ones.
42142
+ //
42143
+ // Writes into job.defs (the same object read by {{X}} interpolation,
42144
+ // -define, -calc and -include).
42145
+ cmd.vars = function(job, opts) {
42146
+ var values = (opts && opts.values) || [];
42147
+ if (!values.length) {
42148
+ stop$1('-vars requires one or more KEY=value or file.json arguments');
42149
+ }
42150
+ var parsed = parseVarsArgs(values, opts && opts.input);
42151
+ if (!job.defs) job.defs = {};
42152
+ Object.keys(parsed).forEach(function(key) {
42153
+ job.defs[key] = parsed[key];
42154
+ });
42155
+ };
42156
+
42157
+ // -defaults KEY=value [KEY=value ...] set-if-unset
42158
+ // Same syntax as -vars, but a key is only assigned if it is not already
42159
+ // present in job.defs. Lets a command file declare overridable defaults
42160
+ // that a CLI -vars can pre-empt.
42161
+ cmd.defaults = function(job, opts) {
42162
+ var values = (opts && opts.values) || [];
42163
+ if (!values.length) {
42164
+ stop$1('-defaults requires one or more KEY=value or file.json arguments');
42165
+ }
42166
+ var parsed = parseVarsArgs(values, opts && opts.input);
42167
+ if (!job.defs) job.defs = {};
42168
+ Object.keys(parsed).forEach(function(key) {
42169
+ if (!(key in job.defs)) {
42170
+ job.defs[key] = parsed[key];
42171
+ }
41743
42172
  });
41744
42173
  };
41745
42174
 
42175
+ // -dissolve2 is now an alias for -dissolve. The repair-on-by-default behavior
42176
+ // of -dissolve2 has been promoted to be the default behavior of -dissolve;
42177
+ // the legacy fast-dissolve algorithm is available via -dissolve no-repair.
42178
+ //
42179
+ // This alias prints a deprecation notice and forwards to cmd.dissolve.
42180
+ //
42181
+ var deprecationWarned = false;
42182
+
42183
+ cmd.dissolve2 = function(layers, dataset, opts) {
42184
+ if (!deprecationWarned && !(opts && opts.quiet)) {
42185
+ message('This command has been merged into -dissolve and is deprecated. ' +
42186
+ 'Use -dissolve (or -dissolve no-repair for the legacy fast algorithm).');
42187
+ deprecationWarned = true;
42188
+ }
42189
+ return cmd.dissolve(layers, dataset, opts);
42190
+ };
42191
+
41746
42192
  // Returns a function for filtering multiple source-table records
41747
42193
  // (used by -join command)
41748
42194
  function getJoinFilter(data, exp) {
@@ -48406,8 +48852,138 @@ ${svg}
48406
48852
  return parsed;
48407
48853
  }
48408
48854
 
48855
+ // Parse the text content of a mapshaper command file (e.g. "commands.txt")
48856
+ // into a single normalized command string suitable for parseCommands().
48857
+ //
48858
+ // Command file syntax (a superset of the equivalent shell command line):
48859
+ // - Optional leading "mapshaper" magic word (used by the file-type sniffer).
48860
+ // - "#" begins a comment that runs to the end of the line. Comments are
48861
+ // ignored unless the "#" appears inside a quoted string.
48862
+ // - Newlines are command separators (unless they fall inside a quoted
48863
+ // string). A trailing backslash on a line is stripped, so shell-style
48864
+ // "\" line continuations are accepted but not required.
48865
+ // - Commands must begin with "-" (e.g. "-i", "-target"). Lines that do
48866
+ // not start with "-" are treated as continuations of the previous
48867
+ // command.
48868
+ // - As on the CLI, an initial command is implied for the first bare token
48869
+ // after the optional "mapshaper" word: a .txt file routes to
48870
+ // "-run <path>" (command file), and any other bare token routes to
48871
+ // "-i <token>" (data file).
48872
+ //
48873
+ // "{{VAR}}" placeholders are substituted at execution time, against the
48874
+ // live job.defs object. See mapshaper-vars-utils.mjs and the late-binding
48875
+ // hook in mapshaper-run-commands.mjs.
48876
+ //
48877
+ function parseCommandFileContent(content) {
48878
+ if (typeof content != 'string') {
48879
+ content = String(content || '');
48880
+ }
48881
+ // Strip BOM if present
48882
+ if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1);
48883
+ var commands = groupCommandFileLines(extractLogicalLines(content));
48884
+ return commands.join(' ');
48885
+ }
48886
+
48887
+ // Walk command file content into logical lines, respecting quoted strings and
48888
+ // stripping "#" comments. Quoted-string contents (including embedded
48889
+ // newlines) are preserved verbatim.
48890
+ function extractLogicalLines(content) {
48891
+ var lines = [];
48892
+ var current = '';
48893
+ var quote = null; // null, "'", or '"'
48894
+ var inComment = false;
48895
+
48896
+ for (var i = 0; i < content.length; i++) {
48897
+ var c = content.charAt(i);
48898
+ if (inComment) {
48899
+ if (c === '\n') {
48900
+ inComment = false;
48901
+ lines.push(current);
48902
+ current = '';
48903
+ }
48904
+ continue;
48905
+ }
48906
+ if (quote) {
48907
+ current += c;
48908
+ if (c === quote) {
48909
+ var bs = 0;
48910
+ for (var j = i - 1; j >= 0 && content.charAt(j) === '\\'; j--) bs++;
48911
+ if (bs % 2 === 0) quote = null;
48912
+ }
48913
+ continue;
48914
+ }
48915
+ if (c === '#') {
48916
+ inComment = true;
48917
+ } else if (c === "'" || c === '"') {
48918
+ quote = c;
48919
+ current += c;
48920
+ } else if (c === '\n') {
48921
+ lines.push(current);
48922
+ current = '';
48923
+ } else {
48924
+ current += c;
48925
+ }
48926
+ }
48927
+ if (current.length > 0) lines.push(current);
48928
+ if (quote) {
48929
+ stop$1('Unterminated quoted string in command file');
48930
+ }
48931
+ return lines;
48932
+ }
48933
+
48934
+ // Group an array of logical lines into command strings:
48935
+ // - Strip trailing-backslash continuations.
48936
+ // - Strip a leading "mapshaper" magic word from the first non-blank line.
48937
+ // - Lines starting with "-" begin a new command.
48938
+ // - Other lines are continuations of the previous command.
48939
+ // - The very first bare token is treated as an implicit -i (data file)
48940
+ // or -run (command file), matching CLI behavior.
48941
+ function groupCommandFileLines(lines) {
48942
+ var commands = [];
48943
+ var cur = '';
48944
+ var sawMagicWord = false;
48945
+ for (var k = 0; k < lines.length; k++) {
48946
+ var line = lines[k];
48947
+ line = line.replace(/\s*\\\s*$/, '').trim();
48948
+ if (!line) continue;
48949
+
48950
+ if (!sawMagicWord && commands.length === 0 && cur === '' &&
48951
+ /^mapshaper(\s|$)/.test(line)) {
48952
+ sawMagicWord = true;
48953
+ line = line.replace(/^mapshaper\s*/, '');
48954
+ if (!line) continue;
48955
+ }
48956
+
48957
+ if (line.charAt(0) === '-') {
48958
+ if (cur) commands.push(cur);
48959
+ cur = line;
48960
+ } else if (!cur && commands.length === 0) {
48961
+ cur = implicitFirstCommand(line);
48962
+ } else {
48963
+ cur += ' ' + line;
48964
+ }
48965
+ }
48966
+ if (cur) commands.push(cur);
48967
+ return commands;
48968
+ }
48969
+
48970
+ // Build the implicit command for a leading bare token:
48971
+ // foo.txt -> -run foo.txt
48972
+ // anything else -> -i <line>
48973
+ // Only the first whitespace-separated token of @line is sniffed for the .txt
48974
+ // extension; any trailing tokens (rare but possible after line joining) are
48975
+ // passed through unchanged.
48976
+ function implicitFirstCommand(line) {
48977
+ var firstTok = line.split(/\s+/)[0];
48978
+ if (isPotentialCommandFile(firstTok)) {
48979
+ return '-run ' + line;
48980
+ }
48981
+ return '-i ' + line;
48982
+ }
48983
+
48409
48984
  var ParseCommands = /*#__PURE__*/Object.freeze({
48410
48985
  __proto__: null,
48986
+ parseCommandFileContent: parseCommandFileContent,
48411
48987
  parseCommands: parseCommands,
48412
48988
  parseConsoleCommands: parseConsoleCommands,
48413
48989
  standardizeConsoleCommands: standardizeConsoleCommands
@@ -48430,7 +49006,9 @@ ${svg}
48430
49006
  }
48431
49007
 
48432
49008
  function commandTakesFileInput(name) {
48433
- return (name == 'i' || name == 'join' || name == 'erase' || name == 'clip' || name == 'include');
49009
+ return (name == 'i' || name == 'join' || name == 'erase' || name == 'clip' ||
49010
+ name == 'include' || name == 'vars' || name == 'defaults' ||
49011
+ name == 'run');
48434
49012
  }
48435
49013
 
48436
49014
  // TODO: implement these and other functions
@@ -48456,11 +49034,116 @@ ${svg}
48456
49034
  // flags.mosaic || flags.snap;
48457
49035
  // }
48458
49036
 
49037
+ // Maximum nesting depth for command files that load other command files
49038
+ var MAX_RUN_DEPTH = 10;
49039
+
49040
+ // Returns the text content of a command file, or null if @file does not look
49041
+ // like a mapshaper command file (wrong extension or missing magic word).
49042
+ // On match, the file content is left in the cache so a subsequent reader
49043
+ // can reuse it.
49044
+ function readCommandFile(file, cache) {
49045
+ if (!isPotentialCommandFile(file)) return null;
49046
+ cli.checkFileExists(file, cache);
49047
+ // cli.readFile(... cache) deletes the entry from the cache after reading,
49048
+ // so we put it back in case downstream code expects to find it there.
49049
+ var content = cli.readFile(file, 'utf8', cache);
49050
+ if (!stringLooksLikeCommandFile(content)) {
49051
+ if (cache) cache[file] = content;
49052
+ return null;
49053
+ }
49054
+ if (cache) cache[file] = content;
49055
+ return content;
49056
+ }
49057
+
49058
+ // Parse and execute the commands in a mapshaper command file within the
49059
+ // given job. Invoked by the -run command when its argument is a .txt file.
49060
+ //
49061
+ // @file: command file path
49062
+ // @content: command file content (string)
49063
+ // @job: parent Job object (commands run in this job)
49064
+ // @opts: options object from the parent -run command (used for input cache and
49065
+ // recursion-depth tracking)
49066
+ //
49067
+ async function runCommandFile(file, content, job, opts) {
49068
+ var depth = (opts && opts._run_depth || 0) + 1;
49069
+ if (depth > MAX_RUN_DEPTH) {
49070
+ stop$1('Command file nesting limit exceeded (' + MAX_RUN_DEPTH + ') at: ' + file);
49071
+ }
49072
+
49073
+ var cache = opts && opts.input || null;
49074
+ var commandStr;
49075
+ try {
49076
+ commandStr = parseCommandFileContent(content);
49077
+ } catch(e) {
49078
+ e.message = 'Error in command file ' + file + ': ' + e.message;
49079
+ throw e;
49080
+ }
49081
+
49082
+ if (!commandStr) {
49083
+ message('Command file contains no commands:', file);
49084
+ return;
49085
+ }
49086
+
49087
+ verbose('Running command file:', file);
49088
+
49089
+ var commands;
49090
+ try {
49091
+ commands = parseCommands(commandStr);
49092
+ } catch(e) {
49093
+ e.message = 'Error in command file ' + file + ': ' + e.message;
49094
+ throw e;
49095
+ }
49096
+
49097
+ // Forward the input cache, output array and depth tracker to nested
49098
+ // commands. This lets a command file's -i find sibling files in the same
49099
+ // cache, lets nested -o commands write to the same output collector (e.g.
49100
+ // when running under applyCommands), and lets nested -run commands respect
49101
+ // the recursion limit.
49102
+ var outputArr = opts && opts.output || null;
49103
+ commands.forEach(function(c) {
49104
+ if (commandTakesFileInput(c.name) && cache) {
49105
+ c.options.input = cache;
49106
+ }
49107
+ if (outputArr && (c.name == 'o' || c.name == 'i' || c.name == 'run' ||
49108
+ c.name == 'info' && c.options.save_to)) {
49109
+ c.options.output = outputArr;
49110
+ }
49111
+ if (c.name == 'run') {
49112
+ c.options._run_depth = depth;
49113
+ }
49114
+ });
49115
+
49116
+ await utils.promisify(runParsedCommands)(commands, job);
49117
+ }
49118
+
48459
49119
  cmd.run = async function(job, targets, opts) {
48460
- var tmp, commands, ctx;
48461
- if (!opts.expression) {
48462
- stop$1("Missing expression parameter");
49120
+ var arg = opts.expression;
49121
+ if (!arg) {
49122
+ stop$1('-run requires a command file path or a JS expression');
49123
+ }
49124
+ // Auto-detect a leading argument that looks like a command file (.txt).
49125
+ // If detection succeeds, treat as a command file; otherwise the argument
49126
+ // is a JS expression (the original -run behavior).
49127
+ if (isPotentialCommandFile(arg)) {
49128
+ if (opts.target) {
49129
+ stop$1('-run does not accept a target= option for command files');
49130
+ }
49131
+ await runFromFile(job, arg, opts);
49132
+ } else {
49133
+ await runFromExpression(job, targets, opts);
49134
+ }
49135
+ };
49136
+
49137
+ async function runFromFile(job, file, opts) {
49138
+ var content = readCommandFile(file, opts.input);
49139
+ if (content === null) {
49140
+ stop$1('Not a mapshaper command file (missing "mapshaper" magic word):', file);
48463
49141
  }
49142
+ await runCommandFile(file, content, job, opts);
49143
+ }
49144
+
49145
+ async function runFromExpression(job, targets, opts) {
49146
+ var tmp, commands, ctx;
48464
49147
  ctx = getBaseContext();
48465
49148
  // io proxy adds ability to add datasets dynamically in a required function
48466
49149
  ctx.io = getIOProxy();
@@ -48474,15 +49157,23 @@ ${svg}
48474
49157
  commands = parseCommands(tmp);
48475
49158
 
48476
49159
  // TODO: remove duplication with mapshaper-run-commands.mjs
49160
+ var outputArr = opts && opts.output || null;
48477
49161
  commands.forEach(function(cmd) {
48478
49162
  if (commandTakesFileInput(cmd.name)) {
48479
49163
  cmd.options.input = ctx.io._cache;
48480
49164
  }
49165
+ // Forward the output collector to commands that produce output, so a
49166
+ // generated -o (or info save_to=, or nested -run) writes into the same
49167
+ // output object (e.g. when running under applyCommands).
49168
+ if (outputArr && (cmd.name == 'o' || cmd.name == 'run' ||
49169
+ cmd.name == 'info' && cmd.options.save_to)) {
49170
+ cmd.options.output = outputArr;
49171
+ }
48481
49172
  });
48482
49173
 
48483
49174
  await utils.promisify(runParsedCommands)(commands, job);
48484
49175
  }
48485
- };
49176
+ }
48486
49177
 
48487
49178
  cmd.shape = function(targetDataset, opts) {
48488
49179
  var geojson, dataset;
@@ -50677,7 +51368,7 @@ ${svg}
50677
51368
  name == 'require' || name == 'run' || name == 'define' ||
50678
51369
  name == 'include' || name == 'print' || name == 'comment' || name == 'if' || name == 'elif' ||
50679
51370
  name == 'else' || name == 'endif' || name == 'stop' || name == 'add-shape' ||
50680
- name == 'scalebar';
51371
+ name == 'scalebar' || name == 'vars' || name == 'defaults';
50681
51372
  }
50682
51373
 
50683
51374
  async function runCommand(command, job) {
@@ -50812,8 +51503,14 @@ ${svg}
50812
51503
  } else if (name == 'define') {
50813
51504
  cmd.define(job.catalog, opts);
50814
51505
 
51506
+ } else if (name == 'vars') {
51507
+ cmd.vars(job, opts);
51508
+
51509
+ } else if (name == 'defaults') {
51510
+ cmd.defaults(job, opts);
51511
+
50815
51512
  } else if (name == 'dissolve') {
50816
- outputLayers = applyCommandToEachLayer(cmd.dissolve, targetLayers, arcs, opts);
51513
+ outputLayers = cmd.dissolve(targetLayers, targetDataset, opts);
50817
51514
 
50818
51515
  } else if (name == 'dissolve2') {
50819
51516
  outputLayers = cmd.dissolve2(targetLayers, targetDataset, opts);
@@ -51131,7 +51828,7 @@ ${svg}
51131
51828
  });
51132
51829
  }
51133
51830
 
51134
- var version = "0.6.120";
51831
+ var version = "0.7.0";
51135
51832
 
51136
51833
  // Parse command line args into commands and run them
51137
51834
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.
@@ -51268,6 +51965,11 @@ ${svg}
51268
51965
  if (outputArr && (cmd.name == 'o' || cmd.name == 'info' && cmd.options.save_to)) {
51269
51966
  cmd.options.output = outputArr;
51270
51967
  }
51968
+ // -run may load and execute a command file; propagate the output array
51969
+ // so that -o commands nested inside it can write to it too.
51970
+ if (outputArr && cmd.name == 'run') {
51971
+ cmd.options.output = outputArr;
51972
+ }
51271
51973
  });
51272
51974
 
51273
51975
  var lastCmd = commands[commands.length - 1];
@@ -51360,7 +52062,13 @@ ${svg}
51360
52062
  utils.reduceAsync(commands, job, nextCommand, done);
51361
52063
 
51362
52064
  function nextCommand(job, cmd, next) {
51363
- runCommand(cmd, job).then(function(result) {
52065
+ var resolved;
52066
+ try {
52067
+ resolved = maybeInterpolateCommand(cmd, job);
52068
+ } catch(e) {
52069
+ return next(e);
52070
+ }
52071
+ runCommand(resolved, job).then(function(result) {
51364
52072
  next(null, result);
51365
52073
  }).catch(function(e) {
51366
52074
  next(e);
@@ -51368,6 +52076,60 @@ ${svg}
51368
52076
  }
51369
52077
  }
51370
52078
 
52079
+ // Late-binding interpolation: just before each command runs, replace any
52080
+ // {{X}} placeholders in its source tokens against the live job.defs object,
52081
+ // then re-parse to get fresh option values.
52082
+ //
52083
+ // Returns either the original cmd (no placeholders, no _tokens, or the
52084
+ // command will be skipped) or a fresh cmd object with re-parsed options.
52085
+ // The original cmd is never mutated, so commands shared across batches
52086
+ // (see divideImportCommand) still see their un-interpolated tokens.
52087
+ function maybeInterpolateCommand(cmd, job) {
52088
+ var tokens = cmd._tokens;
52089
+ if (!tokens || tokens.length === 0) return cmd;
52090
+ if (!tokens.some(containsPlaceholder)) return cmd;
52091
+ // If the command would be skipped (inactive -if branch, stopped job, etc.),
52092
+ // don't try to interpolate -- the command body never runs and unset
52093
+ // variables shouldn't error here.
52094
+ if (skipCommand(cmd.name, job)) return cmd;
52095
+
52096
+ var defs = job.defs || {};
52097
+ var interpolated;
52098
+ try {
52099
+ interpolated = tokens.map(function(tok) {
52100
+ return interpolateString(tok, defs);
52101
+ });
52102
+ } catch(e) {
52103
+ e.message = '[' + cmd.name + '] ' + e.message;
52104
+ throw e;
52105
+ }
52106
+
52107
+ var reparsed;
52108
+ try {
52109
+ reparsed = parseCommands(interpolated);
52110
+ } catch(e) {
52111
+ e.message = '[' + cmd.name + '] ' + e.message;
52112
+ throw e;
52113
+ }
52114
+ if (reparsed.length !== 1) {
52115
+ stop$1('[' + cmd.name + '] Internal error: token re-parse produced ' +
52116
+ reparsed.length + ' commands');
52117
+ }
52118
+ var newCmd = reparsed[0];
52119
+ // Preserve externally-injected options (input cache, output array,
52120
+ // _run_depth, final flag, replace flag, etc.) that aren't reproduced
52121
+ // by re-parsing the source tokens.
52122
+ Object.keys(cmd.options).forEach(function(k) {
52123
+ if (!(k in newCmd.options)) {
52124
+ newCmd.options[k] = cmd.options[k];
52125
+ }
52126
+ });
52127
+ // Keep the original tokens around so re-runs (e.g. divided import batches)
52128
+ // see the un-interpolated source.
52129
+ newCmd._tokens = tokens;
52130
+ return newCmd;
52131
+ }
52132
+
51371
52133
  function handleNonFatalError(err) {
51372
52134
  if (err && err.name == 'NonFatalError') {
51373
52135
  printError(err);
@@ -51391,6 +52153,18 @@ ${svg}
51391
52153
  return [commands];
51392
52154
  }
51393
52155
 
52156
+ // Multiple files trigger batch mode by default. This is a long-standing
52157
+ // wart: most multi-file CLI tools combine inputs by default, and silently
52158
+ // splitting into per-file pipelines is an easy way for users to get wrong
52159
+ // output without noticing. Print a one-time deprecation warning when batch
52160
+ // mode is implicit so existing scripts can migrate before the default flips
52161
+ // in a future major release.
52162
+ if (!opts.batch_mode) {
52163
+ message('Note: implicit batch processing is deprecated. Add `batch-mode` ' +
52164
+ 'to keep this behavior, or `combine-files` to import the files as a ' +
52165
+ 'group of layers. The default will change in a future release.');
52166
+ }
52167
+
51394
52168
  return opts.files.map(function(file) {
51395
52169
  var group = [{
51396
52170
  name: 'i',