mapshaper 0.7.65 → 0.7.66

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
@@ -82789,7 +82789,7 @@ ${svg}
82789
82789
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
82790
82790
  }
82791
82791
 
82792
- var version = "0.7.65";
82792
+ var version = "0.7.66";
82793
82793
 
82794
82794
  // Parse command line args into commands and run them
82795
82795
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mapshaper",
3
- "version": "0.7.65",
3
+ "version": "0.7.66",
4
4
  "description": "A tool for editing geospatial data for mapping and GIS.",
5
5
  "keywords": [
6
6
  "shapefile",
@@ -11373,10 +11373,22 @@
11373
11373
  }
11374
11374
 
11375
11375
  if (lyr && lyr.gui.geographic) {
11376
+ // Above the actions, because it is what they would be applied to: the
11377
+ // labels that share something with the one pointed at, read and chosen
11378
+ // before "copy as GeoJSON" or "delete label" means anything definite.
11379
+ // Each item carries the number it would select, so that what the click
11380
+ // is about to do is visible before it happens.
11381
+ if (e.selectActions?.length) {
11382
+ addMenuLabel('select');
11383
+ e.selectActions.forEach(function(action) {
11384
+ addMenuItem(action.label + getCountHtml(action.count), action.run);
11385
+ });
11386
+ }
11387
+
11376
11388
  if (e.deleteVertex || e.deletePoint || copyable || e.deleteFeature ||
11377
11389
  e.flipLabel) {
11378
11390
 
11379
- addMenuLabel('selection');
11391
+ addMenuLabel('actions');
11380
11392
  if (e.deleteVertex) {
11381
11393
  addMenuItem('delete vertex', e.deleteVertex);
11382
11394
  }
@@ -11524,6 +11536,12 @@
11524
11536
  return 'band values';
11525
11537
  }
11526
11538
 
11539
+ // How many features a "select" item would select, in the grey of a menu
11540
+ // heading: it qualifies the item rather than being part of what it says.
11541
+ function getCountHtml(count) {
11542
+ return ' <span class="contextmenu-count">' + count + '</span>';
11543
+ }
11544
+
11527
11545
  // A color tile, used in place of the bullet that other menu items get.
11528
11546
  function getSwatchHtml(color) {
11529
11547
  return '<span class="contextmenu-swatch" style="background-color: ' +
@@ -16193,6 +16211,13 @@
16193
16211
  setDisplay(shown);
16194
16212
  };
16195
16213
 
16214
+ // What the field shows while it is blank, or '' for nothing. The panel uses
16215
+ // it to say that the selected features disagree about the size, which is one
16216
+ // of the two reasons the field can be empty.
16217
+ this.setPlaceholder = function(str) {
16218
+ input.attr('placeholder', str || '');
16219
+ };
16220
+
16196
16221
  this.setDisabled = function(off) {
16197
16222
  disabled = !!off;
16198
16223
  input.node().disabled = disabled;
@@ -16551,6 +16576,24 @@
16551
16576
  gui.state.new_label_style = null;
16552
16577
  }
16553
16578
 
16579
+ // What a two-state control shows for a group of labels: 'on', 'off', or
16580
+ // 'mixed' when they disagree.
16581
+ //
16582
+ // The panel's other controls say "they disagree" by showing nothing -- an empty
16583
+ // select, an unlit button, a blank field. A switch has no empty state to fall
16584
+ // back on, so it needs a third one named here.
16585
+ //
16586
+ // flags: one truthy/falsy value per label, in any order
16587
+ function getToggleState(flags) {
16588
+ var on = 0;
16589
+ var i;
16590
+ for (i = 0; i < flags.length; i++) {
16591
+ if (flags[i]) on++;
16592
+ }
16593
+ if (on === 0) return 'off';
16594
+ return on == flags.length ? 'on' : 'mixed';
16595
+ }
16596
+
16554
16597
  // What a drag on a label's glyphs means: 'fixed' moves the label, anchor and
16555
16598
  // text together, and 'draggable' leaves the anchor where it is and offsets the
16556
16599
  // text from it.
@@ -16782,6 +16825,11 @@
16782
16825
  // no line-height until one is chosen. "auto" is the honest description of a
16783
16826
  // blank field: something else decides.
16784
16827
  var lineHeightPlaceholder = 'auto';
16828
+ // What a control shows for a selection whose labels disagree about it. One
16829
+ // word, used in every field and menu that can be in that state: the panel
16830
+ // otherwise says it by showing nothing, which is also what an unset property
16831
+ // looks like.
16832
+ var MIXED_TEXT = 'mixed';
16785
16833
  var labelStyleMode = 'label_style';
16786
16834
  var labelStylePanelMode = 'label_style_tool';
16787
16835
  var labelMode = 'label';
@@ -17215,26 +17263,38 @@
17215
17263
  return makePanelSection(panel, title, opts);
17216
17264
  }
17217
17265
 
17218
- // A two-state switch: a track with a knob that sits left when off and right
17219
- // when on, which is the direction users expect and the only thing that says
17220
- // which state is which without a label for each.
17266
+ // A switch: a track with a knob that sits left when off and right when on,
17267
+ // which is the direction users expect and the only thing that says which
17268
+ // state is which without a label for each. A third state says that the
17269
+ // labels it is asking about disagree -- the knob sits over the join of a
17270
+ // half-and-half track, which is the panel's only control that has to show
17271
+ // "some of them" rather than a value.
17272
+ //
17273
+ // role=checkbox rather than switch, which is what it looks like: 'mixed' is a
17274
+ // legal aria-checked value for a checkbox and not for a switch, and a switch
17275
+ // reporting a mixed selection as unchecked would be telling a screen reader
17276
+ // the one thing the third state exists to avoid saying.
17221
17277
  function makeToggle(parent, opts) {
17222
- var track = El('div').addClass('label-toggle').attr('role', 'switch').appendTo(parent);
17223
- var on = false;
17278
+ var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
17279
+ var state = 'off';
17224
17280
  var disabled = false;
17225
17281
  El('div').addClass('label-toggle-knob').appendTo(track);
17226
17282
  if (opts.title) track.attr('title', opts.title);
17283
+ // A click on a mixed switch turns everything on. It is the convention, and
17284
+ // it is the reading that reaches a state the switch can describe: the next
17285
+ // click then turns everything off, so both are one click away.
17227
17286
  track.on('click', function() {
17228
17287
  if (disabled) return;
17229
- opts.onChange(!on);
17288
+ opts.onChange(state != 'on');
17230
17289
  });
17231
17290
  return {
17232
- setState: function(isOn) {
17233
- on = !!isOn;
17234
- track.classed('on', on).attr('aria-checked', on ? 'true' : 'false');
17235
- },
17236
- getState: function() {
17237
- return on;
17291
+ // val: 'on', 'off' or 'mixed'; anything else reads as off
17292
+ setState: function(val) {
17293
+ state = val == 'on' || val == 'mixed' ? val : 'off';
17294
+ track.classed('on', state == 'on')
17295
+ .classed('mixed', state == 'mixed')
17296
+ .attr('aria-checked', state == 'mixed' ? 'mixed' :
17297
+ state == 'on' ? 'true' : 'false');
17238
17298
  },
17239
17299
  setDisabled: function(off) {
17240
17300
  disabled = !!off;
@@ -17257,6 +17317,9 @@
17257
17317
  function makeMeasureInput(parent, field, placeholder) {
17258
17318
  var input = El('input').attr('type', 'text').addClass('label-measure-input')
17259
17319
  .attr('placeholder', placeholder)
17320
+ // The field's own placeholder, kept because the shown one is replaced
17321
+ // while the selection disagrees -- see setMixedPlaceholder().
17322
+ .attr('data-placeholder', placeholder)
17260
17323
  .appendTo(parent)
17261
17324
  .on('change', function() {
17262
17325
  applyStyleValues([[field, input.node().value.trim()]]);
@@ -17428,51 +17491,72 @@
17428
17491
  // With nothing selected the menu shows the font the next label will be
17429
17492
  // made in; with labels selected it shows the font they are drawn in, which
17430
17493
  // for a label carrying none is whatever sans-serif resolves to here.
17431
- var fontVal = getCommonValue(ids, fontField,
17494
+ var font = getShownValue(ids, fontField,
17432
17495
  {useDefault: true, defaultValue: getFontNameForTarget(ids)});
17433
- var fontSizeVal = getCommonValue(ids, fontSizeField, {useDefault: true, defaultValue: defaultFontSize});
17496
+ var fontSize = getShownValue(ids, fontSizeField, {useDefault: true, defaultValue: defaultFontSize});
17434
17497
  var fontStyleVal = getCommonValue(ids, fontStyleField, {useDefault: true, defaultValue: defaultFontStyle});
17435
17498
  var fontWeightVal = getCommonValue(ids, fontWeightField, {useDefault: true, defaultValue: defaultFontWeight});
17436
- var fillVal = getCommonValue(ids, fillField, {useDefault: true, defaultValue: defaultLabelColor});
17437
- var opacityVal = getCommonValue(ids, opacityField, {useDefault: true, defaultValue: 1});
17438
- var letterSpacingVal = getCommonValue(ids, letterSpacingField);
17439
- var lineHeightVal = getCommonValue(ids, lineHeightField);
17499
+ var fill = getShownValue(ids, fillField, {useDefault: true, defaultValue: defaultLabelColor});
17500
+ var opacity = getShownValue(ids, opacityField, {useDefault: true, defaultValue: 1});
17501
+ var letterSpacing = getShownValue(ids, letterSpacingField);
17502
+ var lineHeight = getShownValue(ids, lineHeightField);
17440
17503
  var alignVal = getCommonAlignment(ids);
17441
- var cssVal = getCommonValue(ids, cssField);
17504
+ var css = getShownValue(ids, cssField);
17442
17505
  var posVal = getCommonValue(ids, 'label-pos');
17443
- var iconVal = getCommonValue(ids, iconField);
17444
- var iconSizeVal = getCommonValue(ids, iconSizeField, {useDefault: true, defaultValue: defaultIconSize});
17445
- var iconColorVal = getCommonValue(ids, iconColorField, {useDefault: true, defaultValue: defaultIconColor});
17446
- var iconOpacityVal = getCommonValue(ids, iconOpacityField, {useDefault: true, defaultValue: 1});
17506
+ // From the labels that have a symbol, so that a selection where only some
17507
+ // do shows the symbols it has rather than blanking every field because the
17508
+ // rest of the selection has nothing to compare.
17509
+ var iconIds = getIconValueIds();
17510
+ var iconVal = getCommonValue(iconIds, iconField);
17511
+ var iconSize = getShownValue(iconIds, iconSizeField, {useDefault: true, defaultValue: defaultIconSize});
17512
+ var iconColor = getShownValue(iconIds, iconColorField, {useDefault: true, defaultValue: defaultIconColor});
17513
+ var iconOpacity = getShownValue(iconIds, iconOpacityField, {useDefault: true, defaultValue: 1});
17447
17514
  updateEditingStatus(manualIds.length, !!getLabelTextSession(gui));
17448
17515
  updateSavedStyleControls();
17449
17516
  fontSelect.node().disabled = !showValues;
17450
- updateFontControl(fontVal);
17451
- updateFontStyleControls(fontVal, fontStyleVal, fontWeightVal);
17452
- updateFontSizeControls(showValues ? fontSizeVal : '');
17453
- updateColorControls(showValues ? fillVal : '');
17454
- updateOpacityControl(opacityInput, showValues ? opacityVal : '');
17455
- updateMeasureControl(letterSpacingInput, showValues ? letterSpacingVal : '');
17456
- updateMeasureControl(lineHeightInput, showValues ? lineHeightVal : '');
17517
+ updateFontControl(font);
17518
+ updateFontStyleControls(font.value, fontStyleVal, fontWeightVal);
17519
+ updateFontSizeControls(fontSize);
17520
+ updateColorControls(fill);
17521
+ updateOpacityControl(opacityInput, opacity);
17522
+ updateMeasureControl(letterSpacingInput, letterSpacing);
17523
+ updateMeasureControl(lineHeightInput, lineHeight);
17457
17524
  updateAlignButtons(showValues ? alignVal : '');
17458
- updateCssControl(showValues ? cssVal : '');
17525
+ updateCssControl(css);
17459
17526
  updatePositionButtons(showValues ? posVal : '', ids);
17460
17527
  // The symbol's controls keep showing their values while the switch is off,
17461
17528
  // greyed: what they show is what the symbol comes back as.
17462
17529
  var iconOff = updateIconControls(showValues ? iconVal : '');
17463
- updateIconSizeControls(showValues ? iconSizeVal : '', iconOff);
17464
- updateIconColorControls(showValues ? iconColorVal : '', iconOff);
17465
- updateOpacityControl(iconOpacityInput, showValues ? iconOpacityVal : '', iconOff);
17530
+ updateIconSizeControls(iconSize, iconOff);
17531
+ updateIconColorControls(iconColor, iconOff);
17532
+ updateOpacityControl(iconOpacityInput, iconOpacity, iconOff);
17466
17533
  }
17467
17534
 
17468
- function updateOpacityControl(input, val, disabled) {
17535
+ // Every field that can show a value can also show nothing, which is why each
17536
+ // of these takes a {value, mixed} rather than a value: a field blank because
17537
+ // the selected labels disagree says so, in the placeholder, where one blank
17538
+ // because the property is unset shows what the renderer will do instead.
17539
+ function updateOpacityControl(input, shown, disabled) {
17469
17540
  input.node().disabled = disabled || !controlsEnabled();
17470
- input.node().value = val === '' ? '' : formatOpacityPct(val);
17541
+ input.node().value = shown.value === '' ? '' : formatOpacityPct(shown.value);
17542
+ setMixedPlaceholder(input, shown.mixed);
17471
17543
  }
17472
17544
 
17473
- function updateMeasureControl(input, val) {
17545
+ function updateMeasureControl(input, shown) {
17546
+ var val = shown.value;
17474
17547
  input.node().disabled = !controlsEnabled();
17475
17548
  input.node().value = val || val === 0 ? String(val) : '';
17549
+ setMixedPlaceholder(input, shown.mixed);
17550
+ }
17551
+
17552
+ // A field showing nothing because the labels disagree says "mixed" where it
17553
+ // would otherwise show its own placeholder -- the spacing fields show the
17554
+ // value the renderer uses when the property is absent, which is not what is
17555
+ // true of a mixed selection.
17556
+ function setMixedPlaceholder(input, mixed) {
17557
+ var el = input.node();
17558
+ var own = el.getAttribute('data-placeholder') || '';
17559
+ el.setAttribute('placeholder', mixed ? MIXED_TEXT : own);
17476
17560
  }
17477
17561
 
17478
17562
  // Alignment stays live whatever is selected, including nothing. It was
@@ -17496,7 +17580,6 @@
17496
17580
  // to take. The commands ignore a position given for one, and a disabled
17497
17581
  // button says so where a console warning would not.
17498
17582
  var disabled = !controlsEnabled() || everyLabelIsOnAPath(ids);
17499
- var locked = !disabled && gridIsLockedToCentre(ids);
17500
17583
  // Faintly, and only when no cell is lit: a label carrying offsets is not
17501
17584
  // at any of the nine, but one of them is roughly where it is and clicking
17502
17585
  // it is the way back.
@@ -17504,37 +17587,11 @@
17504
17587
  labelPositions.forEach(function(name) {
17505
17588
  posBtns[name].classed('selected', !disabled && name == pos);
17506
17589
  posBtns[name].classed('nearest', name == nearest);
17507
- setPanelButtonDisabled(posBtns[name], disabled || locked && name != 'c');
17590
+ setPanelButtonDisabled(posBtns[name], disabled);
17508
17591
  });
17509
17592
  updateDragModeButtons();
17510
17593
  }
17511
17594
 
17512
- // The nine positions place text around something, so with nothing drawn at
17513
- // the anchor the grid is locked to the centre cell: there is no answer to
17514
- // "north-east of what?", and the centre is what -add-label gives a new label
17515
- // anyway. The cell stays clickable, because text over its own symbol is a
17516
- // real thing to ask for.
17517
- //
17518
- // The test is whether the label draws a symbol, not whether it has an icon:
17519
- // a styled dots layer given label text by the point panel's "Create labels"
17520
- // keeps its r and fill, and keying this on icon= alone would lock the
17521
- // commonest labels in the app to the middle of their own dots.
17522
- //
17523
- // A default and a guard rather than an invariant. A label that arrives
17524
- // positioned with nothing at its anchor -- from the CLI, from an expression,
17525
- // from the legacy positioning mode -- is shown as it is, with the grid live:
17526
- // refusing to display what is in the record is worse than letting an odd
17527
- // state be edited. Dragging is not gated on a symbol either, since a labels
17528
- // layer whose dots live in a different layer still needs its text placed.
17529
- function gridIsLockedToCentre(ids) {
17530
- var table = getActiveTable();
17531
- if (!everyTargetLacksASymbol(ids)) return false;
17532
- if (ids.length === 0) return !labelIsPlaced(getNewLabelStyle(gui));
17533
- return ids.every(function(id) {
17534
- return !labelIsPlaced(table && table.getRecordAt(id));
17535
- });
17536
- }
17537
-
17538
17595
  // Whether a label has been put somewhere other than on top of its anchor: a
17539
17596
  // position of its own other than the centre one, or offsets from a drag.
17540
17597
  function labelIsPlaced(rec) {
@@ -17544,16 +17601,6 @@
17544
17601
  internal.hasStyleValue(rec, 'dy');
17545
17602
  }
17546
17603
 
17547
- function everyTargetLacksASymbol(ids) {
17548
- var table = getActiveTable();
17549
- if (ids.length === 0) {
17550
- return !internal.featureHasSvgSymbol(getNewLabelStyle(gui));
17551
- }
17552
- return ids.every(function(id) {
17553
- return !internal.featureHasSvgSymbol(table && table.getRecordAt(id));
17554
- });
17555
- }
17556
-
17557
17604
  // Which of the nine positions a dragged label is nearest, or ''.
17558
17605
  //
17559
17606
  // Only for a single label: the cell is a hint about where one label sits,
@@ -17631,8 +17678,9 @@
17631
17678
  return labelModeIsOn() ? 'new labels' : 'all';
17632
17679
  }
17633
17680
 
17634
- function updateFontSizeControls(fontSizeVal) {
17635
- fontSizeInput.setValue(fontSizeVal || '');
17681
+ function updateFontSizeControls(shown) {
17682
+ fontSizeInput.setValue(shown.value || '');
17683
+ fontSizeInput.setPlaceholder(shown.mixed ? MIXED_TEXT : '');
17636
17684
  fontSizeInput.setDisabled(!controlsEnabled());
17637
17685
  }
17638
17686
 
@@ -17645,15 +17693,43 @@
17645
17693
  // here, and a project moves between machines. It is added to the menu as
17646
17694
  // itself rather than replaced by an installed font, since the name is the
17647
17695
  // user's data and choosing something else for them would restyle the label.
17648
- function updateFontControl(fontVal) {
17696
+ function updateFontControl(shown) {
17697
+ var fontVal = shown.value;
17698
+ // A font menu is a list of fonts, so "they are in different fonts" cannot
17699
+ // be one of the fonts: it is an entry that appears only while it is true,
17700
+ // and picking it does nothing (applyFont() ignores a blank value). The
17701
+ // list stays live, so choosing a font is how the selection is brought into
17702
+ // line -- as it is for the face menu below.
17703
+ setMixedOption(fontSelect, shown.mixed);
17649
17704
  fontSelect.node().value = fontVal || '';
17650
17705
  if (fontVal && fontSelect.node().selectedIndex < 0) {
17651
17706
  El('option').attr('value', fontVal).text(fontVal).appendTo(fontSelect);
17652
17707
  fontSelect.node().value = fontVal;
17653
17708
  }
17654
- // Nothing selected, rather than the first font in the list, for a
17655
- // selection whose labels are in different fonts.
17656
- if (!fontVal) fontSelect.node().selectedIndex = -1;
17709
+ // Nothing selected, rather than the first font in the list, for a target
17710
+ // there is nothing to say about -- a disabled menu with no labels behind it.
17711
+ if (!fontVal && !shown.mixed) fontSelect.node().selectedIndex = -1;
17712
+ }
17713
+
17714
+ // Adds or removes a menu's "mixed" entry, which carries no value: a select
17715
+ // showing nothing looks like a control that failed to load, and this is the
17716
+ // one state the panel cannot express by lighting no button.
17717
+ //
17718
+ // First in the list, and removed as soon as the selection agrees, so that it
17719
+ // is never an option among the real ones.
17720
+ function setMixedOption(select, mixed) {
17721
+ var el = select.node();
17722
+ var first = el.options[0];
17723
+ var has = !!first && first.value === '' && first.dataset.mixed == 'true';
17724
+ if (mixed && !has) {
17725
+ first = document.createElement('option');
17726
+ first.value = '';
17727
+ first.textContent = MIXED_TEXT;
17728
+ first.dataset.mixed = 'true';
17729
+ el.insertBefore(first, el.firstChild);
17730
+ } else if (!mixed && has) {
17731
+ el.removeChild(first);
17732
+ }
17657
17733
  }
17658
17734
 
17659
17735
  // The faces the chosen font is installed with, and no entry for "whatever
@@ -17673,12 +17749,13 @@
17673
17749
  El('option').attr('value', variant.value).appendTo(fontStyleSelect).text(variant.label);
17674
17750
  });
17675
17751
  }
17752
+ // The faces are rebuilt above, so the "mixed" entry goes on afterwards.
17753
+ setMixedOption(fontStyleSelect, mixed);
17676
17754
  fontStyleSelect.node().disabled = disabled;
17677
17755
  fontStyleSelect.node().value = shown ? shown.value : '';
17678
- // Nothing selected rather than the first face, for a selection that does
17679
- // not agree on one: the list is still live, so picking a face is how the
17680
- // selection is brought into line.
17681
- if (!shown) fontStyleSelect.node().selectedIndex = -1;
17756
+ // Nothing selected rather than the first face, for a menu with no font to
17757
+ // list the faces of.
17758
+ if (!shown && !mixed) fontStyleSelect.node().selectedIndex = -1;
17682
17759
  }
17683
17760
 
17684
17761
  // The face the panel shows for a label in @fontName. A font-style and
@@ -17689,12 +17766,17 @@
17689
17766
  return getNearestFontStyleVariant(fontName, fontStyleVal, fontWeightVal);
17690
17767
  }
17691
17768
 
17692
- function updateColorControls(colorVal) {
17769
+ function updateColorControls(shown) {
17770
+ var colorVal = shown.value;
17693
17771
  var disabled = !controlsEnabled();
17694
17772
  colorInput.node().disabled = disabled;
17695
17773
  colorInput.node().value = colorVal || '';
17774
+ setMixedPlaceholder(colorInput, shown.mixed);
17696
17775
  colorFieldBox.classed('disabled', disabled);
17697
17776
  setPanelButtonDisabled(colorChit, disabled);
17777
+ // An empty swatch is how the field says "no colour", so a mixed one is
17778
+ // marked instead of being left blank to mean two things.
17779
+ colorChit.classed('mixed', shown.mixed);
17698
17780
  colorChit.css('background-color', isHexColor(colorVal) ? colorVal : 'transparent');
17699
17781
  if (colorPicker.visible()) {
17700
17782
  return; // avoid HSB -> RGB -> HSB rounding jumps after picker commits
@@ -17706,50 +17788,59 @@
17706
17788
  }
17707
17789
  }
17708
17790
 
17709
- function updateCssControl(cssVal) {
17791
+ function updateCssControl(shown) {
17710
17792
  cssInput.node().disabled = !controlsEnabled();
17711
- cssInput.node().value = cssVal || '';
17793
+ cssInput.node().value = shown.value || '';
17794
+ setMixedPlaceholder(cssInput, shown.mixed);
17712
17795
  }
17713
17796
 
17714
17797
  // The toggle reads the data rather than holding a state of its own: a symbol
17715
17798
  // is on when the target has one, which is what makes it follow an undo.
17716
17799
  //
17717
- // A selection where only some labels have a symbol counts as on, so that the
17718
- // section stays usable -- turning the switch off then removes every symbol in
17719
- // it, and a shape applies to all of them. Treating it as off would show the
17720
- // one state from which nothing in the section can be reached.
17800
+ // A selection where only some labels have a symbol shows the switch mixed,
17801
+ // and the section stays usable: there are symbols in the selection to style.
17802
+ // What the controls below it then act on narrows to the labels that have one
17803
+ // -- see getIconTargetIds() -- so styling a symbol never creates one. The
17804
+ // switch is still the only way to ask for that, and clicking a shape is the
17805
+ // one thing in the section that applies to every selected label, because
17806
+ // choosing a shape for a group is a plain statement about all of it.
17721
17807
  function updateIconControls(iconVal) {
17722
17808
  var enabled = controlsEnabled();
17723
- var on = enabled && !everyTargetLacksAnIcon();
17809
+ var state = enabled ? getIconState() : 'off';
17810
+ var off = state == 'off';
17724
17811
  if (iconVal) lastIconShape = iconVal;
17725
- iconToggle.setState(on);
17812
+ iconToggle.setState(state);
17726
17813
  iconToggle.setDisabled(!enabled);
17727
17814
  // The group is faded as a whole rather than button by button, so that the
17728
17815
  // border the buttons share fades with them -- a live border around dead
17729
17816
  // buttons is the one part of a disabled control that still looks usable.
17730
- iconGroupEl.classed('disabled', !on);
17817
+ iconGroupEl.classed('disabled', off);
17731
17818
  iconTypes.forEach(function(icon) {
17732
- iconBtns[icon.name].classed('selected', on && icon.name == iconVal);
17733
- setPanelButtonDisabled(iconBtns[icon.name], !on);
17819
+ iconBtns[icon.name].classed('selected', !off && icon.name == iconVal);
17820
+ setPanelButtonDisabled(iconBtns[icon.name], off);
17734
17821
  });
17735
- return !on;
17822
+ return off;
17736
17823
  }
17737
17824
 
17738
17825
  function setIconOn(on) {
17739
17826
  applyIcon(on ? lastIconShape : '');
17740
17827
  }
17741
17828
 
17742
- function updateIconSizeControls(iconSizeVal, iconOff) {
17743
- iconSizeInput.setValue(iconSizeVal || '');
17829
+ function updateIconSizeControls(shown, iconOff) {
17830
+ iconSizeInput.setValue(shown.value || '');
17831
+ iconSizeInput.setPlaceholder(shown.mixed ? MIXED_TEXT : '');
17744
17832
  iconSizeInput.setDisabled(iconOff || !controlsEnabled());
17745
17833
  }
17746
17834
 
17747
- function updateIconColorControls(colorVal, iconOff) {
17835
+ function updateIconColorControls(shown, iconOff) {
17836
+ var colorVal = shown.value;
17748
17837
  var disabled = iconOff || !controlsEnabled();
17749
17838
  iconColorInput.node().disabled = disabled;
17750
17839
  iconColorInput.node().value = colorVal || '';
17840
+ setMixedPlaceholder(iconColorInput, shown.mixed);
17751
17841
  iconColorFieldBox.classed('disabled', disabled);
17752
17842
  setPanelButtonDisabled(iconColorChit, disabled);
17843
+ iconColorChit.classed('mixed', shown.mixed);
17753
17844
  iconColorChit.css('background-color', isHexColor(colorVal) ? colorVal : 'transparent');
17754
17845
  if (iconColorPicker.visible()) {
17755
17846
  return; // avoid HSB -> RGB -> HSB rounding jumps after picker commits
@@ -17766,29 +17857,57 @@
17766
17857
  }
17767
17858
 
17768
17859
  function getCommonValue(ids, field, opts) {
17860
+ return getCommonValueInfo(ids, field, opts).value;
17861
+ }
17862
+
17863
+ // What the target labels say about one property:
17864
+ //
17865
+ // value: the value they agree on, or '' if they do not
17866
+ // mixed: whether they disagree
17867
+ //
17868
+ // Both, because a control showing nothing has to be able to say which of the
17869
+ // two kinds of nothing it means. Blank is also what an unset property looks
17870
+ // like -- a label carries no letter-spacing until one is chosen -- and a
17871
+ // field that reads the same either way leaves the user to guess whether the
17872
+ // selection is uniform.
17873
+ function getCommonValueInfo(ids, field, opts) {
17769
17874
  var table = getActiveTable();
17770
17875
  var records = table && table.getRecords();
17771
17876
  var value, val, hasValue;
17772
- if (ids.length === 0) return getNewLabelValue(field, opts);
17773
- if (!records) return '';
17877
+ if (ids.length === 0) {
17878
+ return {value: getNewLabelValue(field, opts), mixed: false};
17879
+ }
17880
+ if (!records) return {value: '', mixed: false};
17774
17881
  for (var i=0; i<ids.length; i++) {
17775
17882
  val = records[ids[i]] && records[ids[i]][field];
17776
17883
  if (!val) {
17777
- if (opts && opts.useDefault) {
17778
- val = opts.defaultValue;
17779
- } else {
17780
- return '';
17781
- }
17884
+ // A property nobody set reads as what it renders as, where the caller
17885
+ // says what that is: a label with font-size=12 and one with no
17886
+ // font-size are drawn the same and agree. Without a default the two
17887
+ // disagree, which is right for a property that means nothing when it
17888
+ // is absent -- one label carrying inline css and one not are not in
17889
+ // the same state.
17890
+ val = opts && opts.useDefault ? opts.defaultValue : '';
17782
17891
  } else {
17783
17892
  hasValue = true;
17784
17893
  }
17785
17894
  if (i === 0) {
17786
17895
  value = val;
17787
17896
  } else if (val != value) {
17788
- return '';
17897
+ return {value: '', mixed: true};
17789
17898
  }
17790
17899
  }
17791
- return hasValue || opts && opts.useDefault ? value : '';
17900
+ return {
17901
+ value: hasValue || opts && opts.useDefault ? value : '',
17902
+ mixed: false
17903
+ };
17904
+ }
17905
+
17906
+ // The same, as a control shows it: nothing at all, and nothing to say about
17907
+ // it, while there is no target to read.
17908
+ function getShownValue(ids, field, opts) {
17909
+ if (!controlsEnabled()) return {value: '', mixed: false};
17910
+ return getCommonValueInfo(ids, field, opts);
17792
17911
  }
17793
17912
 
17794
17913
  // The alignment the labels are *drawn* with, rather than the one they carry.
@@ -18017,7 +18136,11 @@
18017
18136
  var ids = getTargetIds();
18018
18137
  var styles = [[iconField, iconName || '']];
18019
18138
  if (iconName) {
18020
- styles.push([iconSizeField, getNumericSize(ids, iconSizeField, defaultIconSize)]);
18139
+ // The size the symbols in the selection already share, where they share
18140
+ // one: a shape applied to a mixed selection gives the labels that had no
18141
+ // symbol the size of the ones that did, rather than resetting them all to
18142
+ // the default.
18143
+ styles.push([iconSizeField, getNumericSize(getIconValueIds(), iconSizeField, defaultIconSize)]);
18021
18144
  // The symbol's own opacity goes on with it, because the label's opacity
18022
18145
  // is applied to both elements: without this, text set to 50% would give
18023
18146
  // a half-faded symbol while the Icon section showed it at 100%.
@@ -18029,25 +18152,21 @@
18029
18152
  applyStyleValues(styles);
18030
18153
  }
18031
18154
 
18032
- // Switching a symbol on or off changes which positions are legal, so the
18033
- // position moves with it -- in the same command, which makes the pair one
18034
- // undo step.
18155
+ // Switching a symbol on moves a label sitting at the centre out from under
18156
+ // it, in the same command, so that the pair is one undo step and the text
18157
+ // is never briefly drawn over the symbol it just asked for.
18035
18158
  //
18036
- // On: the centre cell stops being the default and a label sitting there
18037
- // moves out from under its new symbol. Off: the grid locks back to the
18038
- // centre, so the position goes and the offsets go with it -- label-pos=c
18039
- // clears dx, dy and text-anchor by itself. That makes switching a symbol off
18040
- // destructive, since a hand-placed label loses its placement; being one
18041
- // undoable command is what makes that acceptable.
18159
+ // Switching one off moves nothing. It used to put the label back to the
18160
+ // centre, which threw away a placement the user had made by hand for the
18161
+ // sake of an invariant -- that text with nothing at its anchor sits on the
18162
+ // anchor -- that was never worth what it cost.
18042
18163
  //
18043
18164
  // Not for path labels, whose text runs along a curve: the commands ignore a
18044
18165
  // position given for one and warn about it, and a warning from switching a
18045
18166
  // symbol on would be about something the user did not ask for.
18046
18167
  function addIconPositionChange(styles, ids, iconOn) {
18047
- if (anyLabelIsOnAPath(ids)) return;
18048
- if (!iconOn) {
18049
- styles.push(['label-pos', 'c']);
18050
- } else if (everyTargetIsCentred(ids)) {
18168
+ if (anyLabelIsOnAPath(ids) || !iconOn) return;
18169
+ if (everyTargetIsCentred(ids)) {
18051
18170
  styles.push(['label-pos', labelPositionBesideIcon]);
18052
18171
  }
18053
18172
  }
@@ -18061,45 +18180,74 @@
18061
18180
  }
18062
18181
 
18063
18182
  function applyIconSize(value) {
18064
- applyStyleValues([[iconSizeField, value]]);
18183
+ applyStyleValues([[iconSizeField, value]], getIconTargetIds());
18065
18184
  }
18066
18185
 
18067
18186
  function applyIconColor(color) {
18068
- applyStyleValues([[iconColorField, color]]);
18187
+ applyStyleValues([[iconColorField, color]], getIconTargetIds());
18069
18188
  }
18070
18189
 
18071
18190
  function applyIconOpacity(value) {
18072
- applyStyleValues([[iconOpacityField, value]]);
18191
+ applyStyleValues([[iconOpacityField, value]], getIconTargetIds());
18073
18192
  }
18074
18193
 
18075
18194
  function nudgeIconSize(delta) {
18076
- var ids = getTargetIds();
18077
- var size = getNumericSize(ids, iconSizeField, defaultIconSize);
18195
+ var size = getNumericSize(getIconTargetIds(), iconSizeField, defaultIconSize);
18078
18196
  if (!controlsEnabled()) return;
18079
18197
  size = Math.max(1, size + delta);
18080
18198
  applyIconSize(size);
18081
18199
  }
18082
18200
 
18201
+ // Whether the target labels have a symbol: 'on', 'off', or 'mixed' when only
18202
+ // some of them do.
18203
+ //
18083
18204
  // An icon-size or icon-color on a label with no symbol draws nothing, which
18084
18205
  // is why the shape, size and colour controls are inert until the toggle puts
18085
18206
  // a symbol there to style.
18086
- function everyTargetLacksAnIcon() {
18207
+ function getIconState() {
18087
18208
  var ids = getTargetIds();
18088
18209
  var table = getActiveTable();
18089
- if (ids.length === 0) return !getNewLabelValue(iconField);
18090
- return ids.every(function(id) {
18210
+ if (ids.length === 0) return getNewLabelValue(iconField) ? 'on' : 'off';
18211
+ return getToggleState(ids.map(function(id) {
18091
18212
  var rec = table && table.getRecordAt(id);
18092
- return !(rec && rec[iconField]);
18213
+ return !!(rec && rec[iconField]);
18214
+ }));
18215
+ }
18216
+
18217
+ // The labels an icon-size, icon-color or icon-opacity goes to: those in the
18218
+ // target that have a symbol, which is all of them unless the switch is mixed.
18219
+ // Writing one of these to a label with no icon would add a property that
18220
+ // draws nothing and a column to the user's table.
18221
+ //
18222
+ // Empty for a target with no symbol anywhere, which is the state those
18223
+ // controls are disabled in, and empty for "new labels", where the style is
18224
+ // held by the tool rather than by any feature.
18225
+ function getIconTargetIds() {
18226
+ var ids = getTargetIds();
18227
+ var table = getActiveTable();
18228
+ return ids.filter(function(id) {
18229
+ var rec = table && table.getRecordAt(id);
18230
+ return !!(rec && rec[iconField]);
18093
18231
  });
18094
18232
  }
18095
18233
 
18234
+ // The labels the section's controls *show* the values of, which is not quite
18235
+ // the set they write to: with no symbol anywhere the fields go on showing
18236
+ // what the target carries, greyed, because that is what the symbol comes back
18237
+ // as when the switch is turned on. Narrowing there would show the values held
18238
+ // for the next label instead of the ones stored on the selection.
18239
+ function getIconValueIds() {
18240
+ var ids = getIconTargetIds();
18241
+ return ids.length > 0 ? ids : getTargetIds();
18242
+ }
18243
+
18096
18244
  function getIconOpacityToWrite() {
18097
18245
  var val = parseOpacityValue(iconOpacityInput.node().value);
18098
18246
  if (val !== null) return val;
18099
18247
  // The field reads blank while the section is off, so a symbol switched off
18100
18248
  // and on again takes back the fade still stored on the label rather than
18101
18249
  // being reset to full.
18102
- var stored = getCommonValue(getTargetIds(), iconOpacityField, {useDefault: true, defaultValue: 1});
18250
+ var stored = getCommonValue(getIconValueIds(), iconOpacityField, {useDefault: true, defaultValue: 1});
18103
18251
  val = stored === '' ? 1 : Number(stored);
18104
18252
  return isFinite(val) ? val : 1;
18105
18253
  }
@@ -18152,7 +18300,10 @@
18152
18300
  }
18153
18301
  }
18154
18302
 
18155
- function applyStyleValues(styles) {
18303
+ // idsArg: (optional) the labels to write to, when they are not the whole
18304
+ // target -- an icon property goes only to the labels that have a symbol.
18305
+ function applyStyleValues(styles, idsArg) {
18306
+ var ids = idsArg || getTargetIds();
18156
18307
  if (styles.length === 0) return;
18157
18308
  releaseFocus();
18158
18309
  // What the panel is set to is always what the next label gets, whether or
@@ -18165,8 +18316,8 @@
18165
18316
  // style, so it has to be redrawn to show the change -- it is the preview.
18166
18317
  refreshPendingLabel();
18167
18318
  }
18168
- if (getTargetIds().length > 0) {
18169
- applyStyleCommand(styles);
18319
+ if (ids.length > 0) {
18320
+ applyStyleCommand(styles, ids);
18170
18321
  return;
18171
18322
  }
18172
18323
  if (!labelModeIsOn()) return;
@@ -18178,9 +18329,8 @@
18178
18329
  if (session && session.id == -1 && session.refresh) session.refresh();
18179
18330
  }
18180
18331
 
18181
- function applyStyleCommand(styles) {
18332
+ function applyStyleCommand(styles, ids) {
18182
18333
  var lyr = getActiveLayer();
18183
- var ids = getTargetIds();
18184
18334
  var parts = ['-style'];
18185
18335
  if (!gui.console || !lyr) return;
18186
18336
  styles.forEach(function(style) {
@@ -25361,6 +25511,13 @@
25361
25511
  // layer instead of rebuilding it, and the cue moves with the map by wearing the
25362
25512
  // label's own transform -- but a select-all on a big layer would still pay it,
25363
25513
  // for an outline per label too small to tell apart anyway.
25514
+ //
25515
+ // Over the cap the selected labels wear a halo instead (.label-cue-marked),
25516
+ // which is a class on the text node and costs no measurement. It says less
25517
+ // than an outline -- no anchors, no knots, no box -- but a selection of
25518
+ // hundreds is a group being restyled rather than objects being handled one by
25519
+ // one, and the one thing it has to say is which labels are in it. This used to
25520
+ // draw nothing at all, so selecting a whole layer left the map unchanged.
25364
25521
  var MAX_OUTLINES = 200;
25365
25522
 
25366
25523
  // getEditingId: returns the feature id of an open text editing session, or -1.
@@ -25368,6 +25525,7 @@
25368
25525
  function LabelSelection(gui, ext, hit, getEditingId) {
25369
25526
  var self = {};
25370
25527
  var groups = []; // one <g> per drawn cue, in the layer's markup
25528
+ var marked = []; // text nodes wearing the halo, when there are too many to outline
25371
25529
  var drawn = null; // what those cues represent, so hover does not redraw them
25372
25530
  var on = false;
25373
25531
  var tetherId = -1; // the label whose text is being dragged off its anchor
@@ -25397,9 +25555,11 @@
25397
25555
  // markup and takes the old cues with it.
25398
25556
  self.refresh = function(force) {
25399
25557
  var target = hit.getHitTarget();
25400
- var ids = on ? getDrawableIds() : [];
25401
- var hoverId = on ? getHoverId(ids) : -1;
25402
- var key = ids.join(',') + '/' + hoverId + '/' + tetherId;
25558
+ var selected = on ? hit.getSelectionIds() : [];
25559
+ var tooMany = selected.length > MAX_OUTLINES;
25560
+ var ids = tooMany ? [] : selected;
25561
+ var hoverId = on ? getHoverId(selected) : -1;
25562
+ var key = selected.join(',') + '/' + hoverId + '/' + tetherId;
25403
25563
  // Hover fires on every pointer move, and most of them change nothing here.
25404
25564
  if (!force && drawn === key) return;
25405
25565
  clearAll();
@@ -25414,6 +25574,9 @@
25414
25574
  // would offer handles that cannot be grabbed.
25415
25575
  draw(target, id, 'label-cue-selected', true);
25416
25576
  });
25577
+ // Too many to outline: a halo on the glyphs instead, which is the whole cue
25578
+ // for those labels.
25579
+ if (tooMany) markAll(target, selected);
25417
25580
  };
25418
25581
 
25419
25582
  // The label under the pointer, when showing it would say something: not one
@@ -25425,10 +25588,23 @@
25425
25588
  return id;
25426
25589
  }
25427
25590
 
25428
- // The selected labels, or none if there are too many to outline usefully.
25429
- function getDrawableIds() {
25430
- var ids = hit.getSelectionIds();
25431
- return ids.length > MAX_OUTLINES ? [] : ids;
25591
+ // Puts the halo on each selected label's glyphs. A class of its own rather
25592
+ // than the label_style mode's yellow one: that class is cleared on every
25593
+ // model update while the label tool is on, so the two would fight.
25594
+ function markAll(target, ids) {
25595
+ ids.forEach(function(id) {
25596
+ var nodes = findNodes(target, id);
25597
+ if (!nodes) return;
25598
+ nodes.text.classList.add('label-cue-marked');
25599
+ marked.push(nodes.text);
25600
+ });
25601
+ }
25602
+
25603
+ function clearMarks() {
25604
+ marked.forEach(function(node) {
25605
+ node.classList.remove('label-cue-marked');
25606
+ });
25607
+ marked = [];
25432
25608
  }
25433
25609
 
25434
25610
  function draw(target, id, className, withHandles) {
@@ -25502,7 +25678,8 @@
25502
25678
  var content;
25503
25679
  if (!text) return null;
25504
25680
  content = text.querySelector('textPath') || text;
25505
- return {symbol: symbol, content: content, pathId: getPathId(content)};
25681
+ return {symbol: symbol, text: text, content: content,
25682
+ pathId: getPathId(content)};
25506
25683
  }
25507
25684
 
25508
25685
  // The id of the baseline a path label is laid along, or null for an anchored
@@ -25599,12 +25776,183 @@
25599
25776
  if (g.parentNode) g.parentNode.removeChild(g);
25600
25777
  });
25601
25778
  groups = [];
25779
+ clearMarks();
25602
25780
  drawn = null;
25603
25781
  }
25604
25782
 
25605
25783
  return self;
25606
25784
  }
25607
25785
 
25786
+ // Selecting a group of labels by pointing at one of them: every label on the
25787
+ // layer, or the ones that share its text style, its colour or its symbol.
25788
+ //
25789
+ // Styling a group is something the panel could already do -- a control shows
25790
+ // nothing when the selected labels disagree, and writes to all of them -- but
25791
+ // there was no way to select more than a shift-click's worth. These are the
25792
+ // predicates the context menu offers, as pure functions so that they can be
25793
+ // tested without a map.
25794
+ //
25795
+ // See docs/development/label-tool-design.md.
25796
+
25797
+ // The properties that make up "the same text style": how the glyphs are drawn,
25798
+ // and nothing about where the text sits.
25799
+ //
25800
+ // Alignment, position, offsets and a path label's start offset are left out
25801
+ // deliberately. They are what the panel's other sections edit, and two labels
25802
+ // in the same font at the same size are in the same text style whether one of
25803
+ // them is centred and the other hangs north-east of its anchor -- which is
25804
+ // usually the difference between a label placed by hand and one that was not.
25805
+ //
25806
+ // css and class are in, because either can carry anything the properties above
25807
+ // carry and more: two labels are not in the same text style if one of them is
25808
+ // wearing a stylesheet the other is not.
25809
+ var TEXT_STYLE_FIELDS = [
25810
+ 'font-family', 'font-size', 'font-style', 'font-weight', 'font-stretch',
25811
+ 'letter-spacing', 'line-height', 'css', 'class'
25812
+ ];
25813
+
25814
+ // The colour of the text, and not its opacity: the item says colour, and a
25815
+ // label faded to 50% is the same colour as one that is not.
25816
+ var FILL_FIELDS = ['fill'];
25817
+
25818
+ // The shape of the symbol, and not its size or colour, for the same reason.
25819
+ var ICON_FIELDS = ['icon'];
25820
+
25821
+ // What a property means when a label does not carry it.
25822
+ //
25823
+ // Compared after this substitution, so that a label with font-size=12 and one
25824
+ // with no font-size match: they render identically, and the panel already
25825
+ // treats them as agreeing. Without it the commonest pair of labels on a map --
25826
+ // one the panel has touched and one it has not -- would count as different
25827
+ // text styles while looking the same.
25828
+ //
25829
+ // font-family is deliberately absent. An unset font is whatever the browser
25830
+ // resolves sans-serif to, which differs by machine and is a real difference
25831
+ // from a font the user named, so an unset font is its own value and matches
25832
+ // only another unset one. css, class and icon are absent because they have no
25833
+ // default: not having one is not the same as having a particular one.
25834
+ var STYLE_DEFAULTS = {
25835
+ 'font-size': 12,
25836
+ 'font-style': 'normal',
25837
+ 'font-weight': '400',
25838
+ 'letter-spacing': 0,
25839
+ fill: '#000000'
25840
+ };
25841
+
25842
+ // The items the "select" section offers, in menu order.
25843
+ //
25844
+ // needs: a property the pointed-at label must carry for the item to be worth
25845
+ // offering. "Same icon" on a label with no symbol would select every label
25846
+ // that has none, which is a question nobody asked and which "all labels"
25847
+ // nearly answers anyway.
25848
+ // always: offered whatever it matches, rather than only when it says something
25849
+ // the other items do not. See getLabelSelectActions().
25850
+ var SELECT_KINDS = [
25851
+ {name: 'all', label: 'all labels'},
25852
+ {name: 'text-style', label: 'same text style', fields: TEXT_STYLE_FIELDS,
25853
+ always: true},
25854
+ {name: 'fill', label: 'same fill color', fields: FILL_FIELDS},
25855
+ {name: 'icon', label: 'same icon', fields: ICON_FIELDS, needs: 'icon'}
25856
+ ];
25857
+
25858
+ // Every label on the layer, in ascending id order.
25859
+ //
25860
+ // Not every feature: a labels layer can hold plain points as well -- a dots
25861
+ // layer given label text by the point panel is one -- and a point is not
25862
+ // something the label panel can style.
25863
+ //
25864
+ // records: the layer's data records, or null
25865
+ // isLabel: (rec) -> whether a record is a label at all. Injected rather than
25866
+ // imported so that this module stays testable without the GUI's internals;
25867
+ // the caller passes internal.svg.featureIsLabel.
25868
+ function getAllLabelIds(records, isLabel) {
25869
+ var ids = [];
25870
+ var i;
25871
+ for (i = 0; records && i < records.length; i++) {
25872
+ if (isLabel(records[i])) ids.push(i);
25873
+ }
25874
+ return ids;
25875
+ }
25876
+
25877
+ // The labels that match label @id on @kind, in ascending id order.
25878
+ //
25879
+ // records: the layer's data records, or null
25880
+ // id: the label the predicate is taken from, which is always in the result
25881
+ // kind: 'all', 'text-style', 'fill' or 'icon'
25882
+ // isLabel: as getAllLabelIds()
25883
+ function getMatchingLabelIds(records, id, kind, isLabel) {
25884
+ var defn = findKind(kind);
25885
+ var rec = records && records[id];
25886
+ if (!defn || !rec || !isLabel(rec)) return [];
25887
+ return getAllLabelIds(records, isLabel).filter(function(i) {
25888
+ return !defn.fields || fieldsMatch(records[i], rec, defn.fields);
25889
+ });
25890
+ }
25891
+
25892
+ // The menu items worth offering for a right-click on label @id, as
25893
+ // [{name, label, ids}, ...].
25894
+ //
25895
+ // "All labels" and "same text style" are always offered, the first because it
25896
+ // asks nothing of the layer and the second because it is the item this section
25897
+ // is mostly for: styling the type of a map as a group. Offering it only when it
25898
+ // differs from "all labels" would make it come and go between one right-click
25899
+ // and the next, and its count is worth reading either way -- "same text style
25900
+ // 40" over a layer of 40 says the labels are uniform, which is a fact about the
25901
+ // layer and not a reason to hide the item.
25902
+ //
25903
+ // The rest are dropped rather than shown and disabled when:
25904
+ //
25905
+ // - They would select the label already pointed at and nothing else, which
25906
+ // would look like a way of narrowing the selection to one label -- a plain
25907
+ // click on that label already is one.
25908
+ // - They match every label on the layer, which "all labels" says more plainly.
25909
+ // A layer with one label on it gets no section at all: every item there would
25910
+ // select the label that was right-clicked, which clicking it already does.
25911
+ function getLabelSelectActions(records, id, isLabel) {
25912
+ var rec = records && records[id];
25913
+ var all = getMatchingLabelIds(records, id, 'all', isLabel);
25914
+ var out = [];
25915
+ if (!rec || all.length < 2) return out;
25916
+ SELECT_KINDS.forEach(function(defn) {
25917
+ var always = defn.name == 'all' || defn.always;
25918
+ var ids;
25919
+ if (defn.needs && !hasValue(rec[defn.needs])) return;
25920
+ ids = defn.name == 'all' ? all :
25921
+ getMatchingLabelIds(records, id, defn.name, isLabel);
25922
+ if (!always && (ids.length < 2 || ids.length === all.length)) return;
25923
+ out.push({name: defn.name, label: defn.label, ids: ids});
25924
+ });
25925
+ return out;
25926
+ }
25927
+
25928
+ function findKind(name) {
25929
+ var found = null;
25930
+ SELECT_KINDS.forEach(function(defn) {
25931
+ if (defn.name == name) found = defn;
25932
+ });
25933
+ return found;
25934
+ }
25935
+
25936
+ function fieldsMatch(a, b, fields) {
25937
+ return fields.every(function(field) {
25938
+ return styleValue(a, field) === styleValue(b, field);
25939
+ });
25940
+ }
25941
+
25942
+ // A property as it is compared: what the label carries, or what it means to
25943
+ // carry nothing, as a string so that 12 and '12' are one value.
25944
+ function styleValue(rec, field) {
25945
+ var val = rec ? rec[field] : null;
25946
+ if (!hasValue(val)) {
25947
+ val = field in STYLE_DEFAULTS ? STYLE_DEFAULTS[field] : '';
25948
+ }
25949
+ return String(val);
25950
+ }
25951
+
25952
+ function hasValue(val) {
25953
+ return !(val === undefined || val === null || val === '');
25954
+ }
25955
+
25608
25956
  // State of a label curve being drawn, and the operations the curvature tool
25609
25957
  // performs on it.
25610
25958
  //
@@ -26004,6 +26352,7 @@
26004
26352
  if (!active()) return;
26005
26353
  id = getRightClickedLabel(e);
26006
26354
  if (target && id > -1) {
26355
+ e.selectActions = getSelectActions(target, id);
26007
26356
  e.deleteFeature = getDeleteAction(target, id);
26008
26357
  // Nobody guesses that text is dragged across its own curve, and there is
26009
26358
  // no bracket drawn to suggest it, so the gesture has a second way in.
@@ -26032,6 +26381,43 @@
26032
26381
  return -1;
26033
26382
  }
26034
26383
 
26384
+ // The "select" items for a right-click on label @id: the labels that share
26385
+ // something with it, so that a style can be changed across a group without
26386
+ // shift-clicking every one of it.
26387
+ //
26388
+ // The predicate comes from the label pointed at rather than from whatever
26389
+ // happens to be selected -- a right-click deliberately leaves the selection
26390
+ // alone -- and running an item replaces the selection with what it matched.
26391
+ function getSelectActions(target, id) {
26392
+ var records = target.data ? target.data.getRecords() : null;
26393
+ return getLabelSelectActions(records, id, internal.svg.featureIsLabel)
26394
+ .map(function(action) {
26395
+ return {
26396
+ label: action.label,
26397
+ count: action.ids.length,
26398
+ run: function() { selectLabels(action.ids); }
26399
+ };
26400
+ });
26401
+ }
26402
+
26403
+ // A label being typed into is not a label being styled, so a group selection
26404
+ // ends the session -- which saves its text, as clicking away would. Closing
26405
+ // first and selecting second, because closing puts the label it was editing
26406
+ // back in the selection when the selection is empty.
26407
+ function selectLabels(ids) {
26408
+ if (editor.isOpen()) editor.close();
26409
+ hit.setSelectionIds(ids);
26410
+ }
26411
+
26412
+ // Cmd/Ctrl-A: the keyboard way to the menu's "all labels", for the commonest
26413
+ // of the group selections -- restyling a layer's labels as one.
26414
+ function selectAllLabels() {
26415
+ var target = hit.getHitTarget();
26416
+ var records = target && target.data ? target.data.getRecords() : null;
26417
+ var ids = getAllLabelIds(records, internal.svg.featureIsLabel);
26418
+ if (ids.length > 0) selectLabels(ids);
26419
+ }
26420
+
26035
26421
  // Deleting a label ends whatever was being done to it first: its text
26036
26422
  // session would otherwise write the text back to an id that now belongs to
26037
26423
  // the label that moved up into the gap, and the same shift is why the
@@ -26807,8 +27193,17 @@
26807
27193
  gui.keyboard.on('keydown', function(e) {
26808
27194
  if (!active()) return;
26809
27195
  // While a session is open the textarea has focus and handles its own keys;
26810
- // it stops propagation for the ones it acts on.
27196
+ // it stops propagation for the ones it acts on. Cmd-A there means select
27197
+ // the text being typed, which is the browser's to handle.
26811
27198
  if (editor.isOpen()) return;
27199
+ // Not while a curve is being drawn: that gesture owns the keyboard, and
27200
+ // selecting the layer in the middle of placing a label is not what the
27201
+ // keystroke can have meant.
27202
+ if (isSelectAll(e) && !drawingCurve()) {
27203
+ selectAllLabels();
27204
+ consume(e); // the default is to select the whole page
27205
+ return;
27206
+ }
26812
27207
  if (e.keyName == 'esc') {
26813
27208
  // One rung per press, innermost first: the curve being drawn, then the
26814
27209
  // selection, then the armed tool. The hit control's own escape handler
@@ -26839,6 +27234,16 @@
26839
27234
  }
26840
27235
  }, null, 10);
26841
27236
 
27237
+ // Cmd-A on a Mac, Ctrl-A elsewhere. Read from the key rather than the
27238
+ // keyCode, as undo and redo are.
27239
+ function isSelectAll(e) {
27240
+ var evt = e.originalEvent;
27241
+ if (!evt || !(evt.metaKey || evt.ctrlKey) || evt.shiftKey || evt.altKey) {
27242
+ return false;
27243
+ }
27244
+ return (evt.key || '').toLowerCase() == 'a';
27245
+ }
27246
+
26842
27247
  // Suppresses the key's default action as well as the rest of the GUI's
26843
27248
  // handlers. The default matters here: finishing a curve opens an editing
26844
27249
  // session and focuses its textarea, and without this the same Enter goes on
@@ -30847,8 +31252,7 @@
30847
31252
  var mapLayers = gui.container.findChild('.map-layers').node();
30848
31253
  var map = gui.container.findChild('.mshp-main-map');
30849
31254
  var toggle = gui.buttons.addButton('#frame-tool-icon')
30850
- .addClass('menu-btn preview-toggle')
30851
- .attr('title', 'Toggle map preview');
31255
+ .addClass('menu-btn preview-toggle');
30852
31256
  var readout = El('div')
30853
31257
  .addClass('preview-readout')
30854
31258
  .appendTo(map)
@@ -30968,7 +31372,11 @@
30968
31372
  var available = hasFrame();
30969
31373
  toggle.removeClass('disabled');
30970
31374
  toggle.classed('selected', available && self.isOn());
30971
- toggle.attr('title', available ? 'Toggle map preview' : 'Add map frame');
31375
+ // data-tooltip, not title: the CSS label in .nav-btn[data-tooltip] appears
31376
+ // without the browser's delay and matches the arrow menu's type.
31377
+ var label = available ? 'Toggle map preview' : 'Add map frame';
31378
+ toggle.attr('data-tooltip', label);
31379
+ toggle.attr('aria-label', label);
30972
31380
  toggle.attr('aria-disabled', 'false');
30973
31381
  toggle.attr('aria-pressed', available && self.isOn() ? 'true' : 'false');
30974
31382
  if (gui.map.isPreviewView()) {
package/www/mapshaper.js CHANGED
@@ -82789,7 +82789,7 @@ ${svg}
82789
82789
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
82790
82790
  }
82791
82791
 
82792
- var version = "0.7.65";
82792
+ var version = "0.7.66";
82793
82793
 
82794
82794
  // Parse command line args into commands and run them
82795
82795
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.
package/www/page.css CHANGED
@@ -2259,6 +2259,19 @@ body.pan.panning .map-layers:not(.drawing) {
2259
2259
  vector-effect: non-scaling-stroke;
2260
2260
  }
2261
2261
 
2262
+ /* The cue for a selection too large to outline label by label -- selecting a
2263
+ whole layer to restyle it. A halo in the selection's own blue, the same
2264
+ treatment the older styling mode uses in yellow: at that size the question
2265
+ is which labels are in the selection, not where each one's anchor is. */
2266
+ .map-layers > svg text.label-cue-marked {
2267
+ stroke: #3a7ddc;
2268
+ stroke-opacity: 0.3;
2269
+ paint-order: stroke fill;
2270
+ stroke-width: 4px;
2271
+ stroke-linecap: round;
2272
+ stroke-linejoin: round;
2273
+ }
2274
+
2262
2275
  /* Transparent rather than fill:none / stroke:none, because an unpainted shape
2263
2276
  is not hit-testable. */
2264
2277
  .label-edit-hit-area {
@@ -2412,6 +2425,28 @@ body.pan.panning .map-layers:not(.drawing) {
2412
2425
  fill: white;
2413
2426
  }
2414
2427
 
2428
+ /* Tooltip shown to the left of a nav button on hover. It borrows the type of
2429
+ .nav-menu-item so the strip reads as one family, but stays a flat label --
2430
+ no border, radius or shadow -- so it is not mistaken for the arrow menu.
2431
+ The translucent white wash keeps it legible over dark map content. */
2432
+ .nav-btn[data-tooltip]:hover:not(.disabled)::after {
2433
+ content: attr(data-tooltip);
2434
+ position: absolute;
2435
+ /* Below .nav-sub-menu, which is at -1 and lingers for a moment after the
2436
+ pointer leaves the arrow button: the tooltip must pass behind it. */
2437
+ z-index: -2;
2438
+ top: 50%;
2439
+ right: calc(100% + 2px);
2440
+ transform: translateY(-50%);
2441
+ pointer-events: none;
2442
+ background-color: rgba(255, 255, 255, 0.85);
2443
+ color: var(--colored-text);
2444
+ font-size: 13px;
2445
+ line-height: 13px;
2446
+ padding: 3px 6px 4px 6px;
2447
+ white-space: nowrap;
2448
+ }
2449
+
2415
2450
  .nav-btn.preview-toggle {
2416
2451
  align-items: center;
2417
2452
  box-sizing: border-box;
@@ -3215,7 +3250,8 @@ body.pan.panning .map-layers:not(.drawing) {
3215
3250
 
3216
3251
  /* A switch, because whether the label has a symbol at all is a yes/no that the
3217
3252
  four shapes below it then qualify. Off is a pale track with the knob left,
3218
- on a dark one with the knob right. */
3253
+ on a dark one with the knob right, and mixed -- some of the selected labels
3254
+ on, some off -- is both halves at once with the knob over the join. */
3219
3255
  .label-toggle {
3220
3256
  position: relative;
3221
3257
  flex: none;
@@ -3248,6 +3284,16 @@ body.pan.panning .map-layers:not(.drawing) {
3248
3284
  left: 13px;
3249
3285
  }
3250
3286
 
3287
+ /* The hard edge is the point: a gradient stop rather than a blend, so that the
3288
+ track reads as one half on and one half off rather than as a third colour. */
3289
+ .label-toggle.mixed {
3290
+ background: linear-gradient(to right, #333 50%, #ccc 50%);
3291
+ }
3292
+
3293
+ .label-toggle.mixed .label-toggle-knob {
3294
+ left: 7.5px;
3295
+ }
3296
+
3251
3297
  .label-toggle.disabled {
3252
3298
  opacity: 0.45;
3253
3299
  cursor: default;
@@ -3349,6 +3395,13 @@ body.pan.panning .map-layers:not(.drawing) {
3349
3395
  border-radius: 2px;
3350
3396
  }
3351
3397
 
3398
+ /* A swatch for a selection whose colours disagree: split rather than empty,
3399
+ because empty is how the swatch says the colour is unset. Two greys, so that
3400
+ it cannot be read as one of the colours in the selection. */
3401
+ .label-style-panel .label-color-field .label-color-chit.mixed {
3402
+ background-image: linear-gradient(135deg, #d9d9d9 0 50%, #8c8c8c 50% 100%);
3403
+ }
3404
+
3352
3405
  .label-style-panel .label-color-field input[type="text"] {
3353
3406
  flex: 1;
3354
3407
  width: auto;
@@ -3761,21 +3814,35 @@ body.dragging-color-picker * {
3761
3814
  .contextmenu-item {
3762
3815
  background-color: #fff;
3763
3816
  white-space: nowrap;
3764
- padding: 3px 10px 4px 10px;
3817
+ /* padding: 3px 10px 4px 10px; */
3818
+ margin: 1px 10px;
3819
+ padding: 2px 0;
3765
3820
  cursor: pointer;
3766
- font-size: 13px;
3821
+ font-size: 12px;
3767
3822
  line-height: 13px;
3768
3823
  }
3769
3824
 
3770
3825
  .contextmenu-label {
3771
3826
  color: #999;
3772
3827
  white-space: nowrap;
3773
- padding: 4px 10px 1px 10px;
3828
+ padding: 5px 10px 1px 10px;
3774
3829
  font-size: 12px;
3775
3830
  line-height: 12px;
3776
3831
  margin-top: -2px;
3777
3832
  }
3778
3833
 
3834
+ /* How many labels a "select" item would select. In the grey of a menu heading
3835
+ rather than the item's own colour: it qualifies what the item says rather
3836
+ than being part of it. */
3837
+ .contextmenu-count {
3838
+ font-size: 10px;
3839
+ color: #999;
3840
+ }
3841
+
3842
+ .contextmenu-item:hover .contextmenu-count {
3843
+ color: #777;
3844
+ }
3845
+
3779
3846
  /* color tile shown instead of a bullet in the raster color readout */
3780
3847
  .contextmenu-swatch {
3781
3848
  display: inline-block;