mapshaper 0.5.83 → 0.5.87

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.
@@ -3574,2540 +3574,2695 @@
3574
3574
  }
3575
3575
  }
3576
3576
 
3577
- function SidebarButtons(gui) {
3578
- var root = gui.container.findChild('.mshp-main-map');
3579
- var buttons = El('div').addClass('nav-buttons').appendTo(root).hide();
3580
- var _hidden = true;
3581
- gui.on('active', updateVisibility);
3582
- gui.on('inactive', updateVisibility);
3583
-
3584
- // @iconRef: selector for an (svg) button icon
3585
- this.addButton = function(iconRef) {
3586
- var btn = initButton(iconRef).addClass('nav-btn');
3587
- btn.appendTo(buttons);
3588
- return btn;
3589
- };
3590
-
3591
- this.show = function() {
3592
- _hidden = false;
3593
- updateVisibility();
3594
- };
3577
+ function getSymbolNodeId(node) {
3578
+ return parseInt(node.getAttribute('data-id'));
3579
+ }
3595
3580
 
3596
- this.hide = function() {
3597
- _hidden = true;
3598
- updateVisibility();
3599
- };
3581
+ function getSvgSymbolTransform(xy, ext) {
3582
+ var scale = ext.getSymbolScale();
3583
+ var p = ext.translateCoords(xy[0], xy[1]);
3584
+ return internal.svg.getTransform(p, scale);
3585
+ }
3600
3586
 
3601
- var initButton = this.initButton = function(iconRef) {
3602
- var icon = El('body').findChild(iconRef).node().cloneNode(true);
3603
- var btn = El('div')
3604
- .on('dblclick', function(e) {e.stopPropagation();}); // block dblclick zoom
3605
- btn.appendChild(icon);
3606
- if (icon.hasAttribute('id')) icon.removeAttribute('id');
3607
- return btn;
3608
- };
3609
3587
 
3610
- function updateVisibility() {
3611
- if (GUI.isActiveInstance(gui) && !_hidden) {
3612
- buttons.show();
3588
+ function repositionSymbols(elements, layer, ext) {
3589
+ var el, idx, shp, p, displayOn, inView, displayBounds;
3590
+ for (var i=0, n=elements.length; i<n; i++) {
3591
+ el = elements[i];
3592
+ idx = getSymbolNodeId(el);
3593
+ shp = layer.shapes[idx];
3594
+ if (!shp) continue;
3595
+ p = shp[0];
3596
+ // OPTIMIZATION: only display symbols that are in view
3597
+ // quick-and-dirty hit-test: expand the extent rectangle by a percentage.
3598
+ // very large symbols will disappear before they're completely out of view
3599
+ displayBounds = ext.getBounds(1.15);
3600
+ displayOn = !el.hasAttribute('display') || el.getAttribute('display') == 'block';
3601
+ inView = displayBounds.containsPoint(p[0], p[1]);
3602
+ if (inView) {
3603
+ if (!displayOn) el.setAttribute('display', 'block');
3604
+ el.setAttribute('transform', getSvgSymbolTransform(p, ext));
3613
3605
  } else {
3614
- buttons.hide();
3606
+ if (displayOn) el.setAttribute('display', 'none');
3615
3607
  }
3616
3608
  }
3617
3609
  }
3618
3610
 
3619
- function ModeButton(modes, el, name) {
3620
- var btn = El(el),
3621
- active = false;
3622
- modes.on('mode', function(e) {
3623
- active = e.name == name;
3624
- if (active) {
3625
- btn.addClass('active');
3626
- } else {
3627
- btn.removeClass('active');
3628
- }
3629
- });
3630
-
3631
- btn.on('click', function() {
3632
- modes.enterMode(active ? null : name);
3611
+ function renderSymbols(lyr, ext, type) {
3612
+ var records = lyr.data.getRecords();
3613
+ var symbols = lyr.shapes.map(function(shp, i) {
3614
+ var d = records[i];
3615
+ var obj = type == 'label' ? internal.svg.importStyledLabel(d) :
3616
+ internal.svg.importSymbol(d['svg-symbol']);
3617
+ if (!obj || !shp) return null;
3618
+ obj.properties.transform = getSvgSymbolTransform(shp[0], ext);
3619
+ obj.properties['data-id'] = i;
3620
+ return obj;
3633
3621
  });
3622
+ var obj = internal.getEmptyLayerForSVG(lyr, {});
3623
+ obj.children = symbols;
3624
+ return internal.svg.stringify(obj);
3634
3625
  }
3635
3626
 
3636
- function ModeSwitcher() {
3637
- var self = this;
3638
- var mode = null;
3627
+ function isMultilineLabel(textNode) {
3628
+ return textNode.childNodes.length > 1;
3629
+ }
3639
3630
 
3640
- self.getMode = function() {
3641
- return mode;
3642
- };
3631
+ function toggleTextAlign(textNode, rec) {
3632
+ var curr = rec['text-anchor'] || 'middle';
3633
+ var value = curr == 'middle' && 'start' || curr == 'start' && 'end' || 'middle';
3634
+ updateTextAnchor(value, textNode, rec);
3635
+ }
3643
3636
 
3644
- // return a function to trigger this mode
3645
- self.addMode = function(name, enter, exit, btn) {
3646
- self.on('mode', function(e) {
3647
- if (e.prev == name) {
3648
- exit();
3649
- }
3650
- if (e.name == name) {
3651
- enter();
3652
- }
3653
- });
3654
- if (btn) {
3655
- new ModeButton(self, btn, name);
3637
+ // Set an attribute on a <text> node and any child <tspan> elements
3638
+ // (mapshaper's svg labels require tspans to have the same x and dx values
3639
+ // as the enclosing text node)
3640
+ function setMultilineAttribute(textNode, name, value) {
3641
+ var n = textNode.childNodes.length;
3642
+ var i = -1;
3643
+ var child;
3644
+ textNode.setAttribute(name, value);
3645
+ while (++i < n) {
3646
+ child = textNode.childNodes[i];
3647
+ if (child.tagName == 'tspan') {
3648
+ child.setAttribute(name, value);
3656
3649
  }
3657
- };
3650
+ }
3651
+ }
3658
3652
 
3659
- self.addMode(null, function() {}, function() {}); // null mode
3653
+ function findSvgRoot(el) {
3654
+ while (el && el.tagName != 'html' && el.tagName != 'body') {
3655
+ if (el.tagName == 'svg') return el;
3656
+ el = el.parentNode;
3657
+ }
3658
+ return null;
3659
+ }
3660
3660
 
3661
- self.clearMode = function() {
3662
- self.enterMode(null);
3663
- };
3661
+ // p: pixel coordinates of label anchor
3662
+ function autoUpdateTextAnchor(textNode, rec, p) {
3663
+ var svg = findSvgRoot(textNode);
3664
+ var rect = textNode.getBoundingClientRect();
3665
+ var labelCenterX = rect.left - svg.getBoundingClientRect().left + rect.width / 2;
3666
+ var xpct = (labelCenterX - p[0]) / rect.width; // offset of label center from anchor center
3667
+ var value = xpct < -0.25 && 'end' || xpct > 0.25 && 'start' || 'middle';
3668
+ updateTextAnchor(value, textNode, rec);
3669
+ }
3664
3670
 
3665
- self.enterMode = function(next) {
3666
- var prev = mode;
3667
- if (next != prev) {
3668
- mode = next;
3669
- self.dispatchEvent('mode', {name: next, prev: prev});
3670
- }
3671
- };
3671
+ // @value: optional position to set; if missing, auto-set
3672
+ function updateTextAnchor(value, textNode, rec) {
3673
+ var rect = textNode.getBoundingClientRect();
3674
+ var width = rect.width;
3675
+ var curr = rec['text-anchor'] || 'middle';
3676
+ var xshift = 0;
3677
+
3678
+ // console.log("anchor() curr:", curr, "xpct:", xpct, "left:", rect.left, "anchorX:", anchorX, "targ:", targ, "dx:", xshift)
3679
+ if (curr == 'middle' && value == 'end' || curr == 'start' && value == 'middle') {
3680
+ xshift = width / 2;
3681
+ } else if (curr == 'middle' && value == 'start' || curr == 'end' && value == 'middle') {
3682
+ xshift = -width / 2;
3683
+ } else if (curr == 'start' && value == 'end') {
3684
+ xshift = width;
3685
+ } else if (curr == 'end' && value == 'start') {
3686
+ xshift = -width;
3687
+ }
3688
+ if (xshift) {
3689
+ rec['text-anchor'] = value;
3690
+ applyDelta(rec, 'dx', Math.round(xshift));
3691
+ }
3672
3692
  }
3673
3693
 
3674
- utils.inherit(ModeSwitcher, EventDispatcher);
3694
+ // handle either numeric strings or numbers in fields
3695
+ function applyDelta(rec, key, delta) {
3696
+ var currVal = rec[key];
3697
+ var isString = utils.isString(currVal);
3698
+ var newVal = (+currVal + delta) || 0;
3699
+ rec[key] = isString ? String(newVal) : newVal;
3700
+ }
3675
3701
 
3676
- function KeyboardEvents(gui) {
3677
- var self = this;
3678
- var shiftDown = false;
3679
- document.addEventListener('keyup', function(e) {
3680
- if (!GUI.isActiveInstance(gui)) return;
3681
- if (e.keyCode == 16) shiftDown = false;
3682
- });
3702
+ function getDisplayCoordsById(id, layer, ext) {
3703
+ var coords = getPointCoordsById(id, layer);
3704
+ return ext.translateCoords(coords[0], coords[1]);
3705
+ }
3683
3706
 
3684
- document.addEventListener('keydown', function(e) {
3685
- if (!GUI.isActiveInstance(gui)) return;
3686
- if (e.keyCode == 16) shiftDown = true;
3687
- self.dispatchEvent('keydown', {originalEvent: e});
3707
+ function snapVerticesToPoint(ids, p, arcs, final) {
3708
+ ids.forEach(function(idx) {
3709
+ internal.setVertexCoords(p[0], p[1], idx, arcs);
3688
3710
  });
3711
+ if (final) {
3712
+ // kludge to get dataset to recalculate internal bounding boxes
3713
+ arcs.transformPoints(function() {});
3714
+ }
3715
+ }
3689
3716
 
3690
- this.shiftIsPressed = function() { return shiftDown; };
3717
+ function getPointCoordsById(id, layer) {
3718
+ var coords = layer && layer.geometry_type == 'point' && layer.shapes[id];
3719
+ if (!coords || coords.length != 1) {
3720
+ return null;
3721
+ }
3722
+ return coords[0];
3723
+ }
3691
3724
 
3692
- this.onMenuSubmit = function(menuEl, cb) {
3693
- gui.on('enter_key', function(e) {
3694
- if (menuEl.visible()) {
3695
- e.originalEvent.stopPropagation();
3696
- cb();
3697
- }
3698
- });
3699
- };
3725
+ function translateDeltaDisplayCoords(dx, dy, ext) {
3726
+ var a = ext.translatePixelCoords(0, 0);
3727
+ var b = ext.translatePixelCoords(dx, dy);
3728
+ return [b[0] - a[0], b[1] - a[1]];
3700
3729
  }
3701
3730
 
3702
- utils.inherit(KeyboardEvents, EventDispatcher);
3703
3731
 
3704
- function InteractionMode(gui) {
3732
+ function SymbolDragging2(gui, ext, hit) {
3733
+ // var targetTextNode; // text node currently being dragged
3734
+ var dragging = false;
3735
+ var activeRecord;
3736
+ var activeId = -1;
3737
+ var self = new EventDispatcher();
3738
+ var activeVertexIds = null; // for vertex dragging
3705
3739
 
3706
- var menus = {
3707
- standard: ['info', 'selection', 'data', 'box'],
3708
- lines: ['info', 'selection', 'data', 'box', 'vertices'],
3709
- table: ['info', 'selection', 'data'],
3710
- labels: ['info', 'selection', 'data', 'box', 'labels', 'location'],
3711
- points: ['info', 'selection', 'data', 'box', 'location']
3712
- };
3740
+ initDragging();
3713
3741
 
3714
- var prompts = {
3715
- box: 'Shift-drag to draw a box',
3716
- data: 'Click-select features to edit their attributes',
3717
- selection: 'Click-select or shift-drag to select features'
3718
- };
3742
+ return self;
3719
3743
 
3720
- // mode name -> menu text lookup
3721
- var labels = {
3722
- info: 'inspect features',
3723
- box: 'shift-drag box tool',
3724
- data: 'edit attributes',
3725
- labels: 'position labels',
3726
- location: 'drag points',
3727
- vertices: 'drag vertices',
3728
- selection: 'select features',
3729
- off: 'turn off'
3730
- };
3731
- var btn, menu;
3732
- var _menuTimeout;
3744
+ function labelEditingEnabled() {
3745
+ return gui.interaction && gui.interaction.getMode() == 'labels' ? true : false;
3746
+ }
3733
3747
 
3734
- // state variables
3735
- var _editMode = 'off';
3736
- var _menuOpen = false;
3748
+ function locationEditingEnabled() {
3749
+ return gui.interaction && gui.interaction.getMode() == 'location' ? true : false;
3750
+ }
3737
3751
 
3738
- // Only render edit mode button/menu if this option is present
3739
- if (gui.options.inspectorControl) {
3740
- btn = gui.buttons.addButton('#pointer-icon').addClass('menu-btn');
3741
- menu = El('div').addClass('nav-sub-menu').appendTo(btn.node());
3752
+ function vertexEditingEnabled() {
3753
+ return gui.interaction && gui.interaction.getMode() == 'vertices' ? true : false;
3754
+ }
3742
3755
 
3743
- btn.on('mouseleave', function() {
3744
- if (!_menuOpen) {
3745
- btn.removeClass('hover');
3746
- } else {
3747
- closeMenu(200);
3756
+ // update symbol by setting attributes
3757
+ function updateSymbol(node, d) {
3758
+ var a = d['text-anchor'];
3759
+ if (a) node.setAttribute('text-anchor', a);
3760
+ setMultilineAttribute(node, 'dx', d.dx || 0);
3761
+ node.setAttribute('y', d.dy || 0);
3762
+ }
3763
+
3764
+ // update symbol by re-rendering it
3765
+ function updateSymbol2(node, d, id) {
3766
+ var o = internal.svg.importStyledLabel(d); // TODO: symbol support
3767
+ var activeLayer = hit.getHitTarget().layer;
3768
+ var xy = activeLayer.shapes[id][0];
3769
+ var g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
3770
+ var node2;
3771
+ o.properties.transform = getSvgSymbolTransform(xy, ext);
3772
+ o.properties['data-id'] = id;
3773
+ // o.properties['class'] = 'selected';
3774
+ g.innerHTML = internal.svg.stringify(o);
3775
+ node2 = g.firstChild;
3776
+ node.parentNode.replaceChild(node2, node);
3777
+ gui.dispatchEvent('popup-needs-refresh');
3778
+ return node2;
3779
+ }
3780
+
3781
+ function initDragging() {
3782
+ var downEvt;
3783
+ var eventPriority = 1;
3784
+
3785
+ // inspector and label editing aren't fully synced - stop editing if inspector opens
3786
+ // gui.on('inspector_on', function() {
3787
+ // stopEditing();
3788
+ // });
3789
+
3790
+ gui.on('interaction_mode_change', function(e) {
3791
+ if (e.mode != 'labels') {
3792
+ stopDragging();
3748
3793
  }
3794
+ gui.undo.clear(); // TODO: put this elsewhere?
3749
3795
  });
3750
3796
 
3751
- btn.on('mouseenter', function() {
3752
- btn.addClass('hover');
3753
- if (_menuOpen) {
3754
- clearTimeout(_menuTimeout); // prevent timed closing
3755
- } else {
3756
- openMenu();
3797
+ // down event on svg
3798
+ // a: off text
3799
+ // -> stop editing
3800
+ // b: on text
3801
+ // 1: not editing -> nop
3802
+ // 2: on selected text -> start dragging
3803
+ // 3: on other text -> stop dragging, select new text
3804
+
3805
+ hit.on('dragstart', function(e) {
3806
+ if (e.id >= 0 === false) return;
3807
+ if (labelEditingEnabled() && onLabelDragStart(e)) {
3808
+ triggerGlobalEvent('label_dragstart', e);
3809
+ startDragging();
3810
+ } else if (locationEditingEnabled()) {
3811
+ triggerGlobalEvent('symbol_dragstart', e);
3812
+ startDragging();
3813
+ } else if (vertexEditingEnabled()) {
3814
+ onVertexDragStart(e);
3815
+ triggerGlobalEvent('vertex_dragstart', e);
3816
+ startDragging();
3757
3817
  }
3758
- // if (_editMode != 'off') {
3759
- // openMenu();
3760
- // }
3761
3818
  });
3762
3819
 
3763
- btn.on('click', function(e) {
3764
- if (active()) {
3765
- setMode('off');
3766
- closeMenu();
3767
- } else if (_menuOpen) {
3768
- setMode('info'); // select info (inspect) as the default
3769
- // closeMenu(350);
3770
- } else {
3771
- openMenu();
3820
+ hit.on('drag', function(e) {
3821
+ if (labelEditingEnabled()) {
3822
+ onLabelDrag(e);
3823
+ } else if (locationEditingEnabled()) {
3824
+ onLocationDrag(e);
3825
+ } else if (vertexEditingEnabled()) {
3826
+ onVertexDrag(e);
3772
3827
  }
3773
- e.stopPropagation();
3774
3828
  });
3775
- }
3776
3829
 
3777
- this.turnOff = function() {
3778
- setMode('off');
3779
- };
3830
+ hit.on('dragend', function(e) {
3831
+ if (locationEditingEnabled()) {
3832
+ triggerGlobalEvent('symbol_dragend', e);
3833
+ stopDragging();
3834
+ } else if (labelEditingEnabled()) {
3835
+ triggerGlobalEvent('label_dragend', e);
3836
+ stopDragging();
3837
+ } else if (vertexEditingEnabled()) {
3838
+ // kludge to get dataset to recalculate internal bounding boxes
3839
+ hit.getHitTarget().arcs.transformPoints(function() {});
3840
+ triggerGlobalEvent('vertex_dragend', e);
3841
+ stopDragging();
3842
+ }
3843
+ });
3780
3844
 
3781
- this.getMode = getInteractionMode;
3845
+ hit.on('click', function(e) {
3846
+ if (labelEditingEnabled()) {
3847
+ onLabelClick(e);
3848
+ }
3849
+ });
3782
3850
 
3783
- this.setMode = function(mode) {
3784
- // TODO: check that this mode is valid for the current dataset
3785
- if (mode in labels) {
3786
- setMode(mode);
3851
+ function getVertexEventData(e) {
3852
+ return {
3853
+ FID: activeId,
3854
+ vertexIds: activeVertexIds
3855
+ };
3787
3856
  }
3788
- };
3789
3857
 
3790
- gui.model.on('update', function(e) {
3791
- // change mode if active layer doesn't support the current mode
3792
- updateCurrentMode();
3793
- if (_menuOpen) {
3794
- renderMenu();
3858
+ function onLocationDrag(e) {
3859
+ var lyr = hit.getHitTarget().layer;
3860
+ var p = getPointCoordsById(e.id, lyr);
3861
+ if (!p) return;
3862
+ var diff = translateDeltaDisplayCoords(e.dx, e.dy, ext);
3863
+ p[0] += diff[0];
3864
+ p[1] += diff[1];
3865
+ self.dispatchEvent('location_change'); // signal map to redraw
3866
+ triggerGlobalEvent('symbol_drag', e);
3795
3867
  }
3796
- }, null, -1); // low priority?
3797
-
3798
- function active() {
3799
- return _editMode && _editMode != 'off';
3800
- }
3801
3868
 
3802
- function getAvailableModes() {
3803
- var o = gui.model.getActiveLayer();
3804
- if (!o || !o.layer) {
3805
- return menus.standard; // TODO: more sensible handling of missing layer
3806
- }
3807
- if (!internal.layerHasGeometry(o.layer)) {
3808
- return menus.table;
3809
- }
3810
- if (internal.layerHasLabels(o.layer)) {
3811
- return menus.labels;
3812
- }
3813
- if (internal.layerHasPoints(o.layer)) {
3814
- return menus.points;
3815
- }
3816
- if (internal.layerHasPaths(o.layer) && o.layer.geometry_type == 'polyline') {
3817
- return menus.lines;
3869
+ function onVertexDragStart(e) {
3870
+ var target = hit.getHitTarget();
3871
+ var p = ext.translatePixelCoords(e.x, e.y);
3872
+ activeVertexIds = internal.findNearestVertices(p, target.layer.shapes[e.id], target.arcs);
3873
+ activeId = e.id;
3818
3874
  }
3819
- return menus.standard;
3820
- }
3821
3875
 
3822
- function getInteractionMode() {
3823
- return active() ? _editMode : 'off';
3824
- }
3876
+ function onVertexDrag(e) {
3877
+ var target = hit.getHitTarget();
3878
+ if (!activeVertexIds) return; // ignore error condition
3879
+ var p = ext.translatePixelCoords(e.x, e.y);
3880
+ if (gui.keyboard.shiftIsPressed()) {
3881
+ internal.snapPointToArcEndpoint(p, activeVertexIds, target.arcs);
3882
+ }
3883
+ snapVerticesToPoint(activeVertexIds, p, target.arcs);
3884
+ self.dispatchEvent('location_change'); // signal map to redraw
3885
+ }
3825
3886
 
3826
- function renderMenu() {
3827
- if (!menu) return;
3828
- var modes = getAvailableModes();
3829
- menu.empty();
3830
- modes.forEach(function(mode) {
3831
- // don't show "turn off" link if not currently editing
3832
- if (_editMode == 'off' && mode == 'off') return;
3833
- var link = El('div').addClass('nav-menu-item').attr('data-name', mode).text(labels[mode]).appendTo(menu);
3834
- link.on('click', function(e) {
3835
- if (_editMode == mode) {
3836
- // closeMenu();
3837
- setMode('off');
3838
- } else if (_editMode != mode) {
3839
- setMode(mode);
3840
- if (mode == 'off') closeMenu(120); // only close if turning off
3841
- // closeMenu(mode == 'off' ? 120 : 400); // close after selecting
3842
- }
3843
- e.stopPropagation();
3844
- });
3845
- });
3846
- updateSelectionHighlight();
3847
- }
3887
+ function onLabelClick(e) {
3888
+ var textNode = getTextTarget3(e);
3889
+ var rec = getLabelRecordById(e.id);
3890
+ if (textNode && rec && isMultilineLabel(textNode)) {
3891
+ toggleTextAlign(textNode, rec);
3892
+ updateSymbol2(textNode, rec, e.id);
3893
+ // e.stopPropagation(); // prevent pin/unpin on popup
3894
+ }
3895
+ }
3848
3896
 
3849
- // if current editing mode is not available, turn off the tool
3850
- function updateCurrentMode() {
3851
- var modes = getAvailableModes();
3852
- if (modes.indexOf(_editMode) == -1) {
3853
- setMode('off');
3897
+ function triggerGlobalEvent(type, e) {
3898
+ if (e.id >= 0 === false) return;
3899
+ var o = {
3900
+ FID: e.id,
3901
+ layer_name: hit.getHitTarget().layer.name,
3902
+ vertex_ids: activeVertexIds
3903
+ };
3904
+ // fire event to signal external editor that symbol coords have changed
3905
+ gui.dispatchEvent(type, o);
3854
3906
  }
3855
- }
3856
3907
 
3857
- function openMenu() {
3858
- clearTimeout(_menuTimeout);
3859
- if (!_menuOpen) {
3860
- _menuOpen = true;
3861
- renderMenu();
3862
- updateArrowButton();
3908
+ function getLabelRecordById(id) {
3909
+ var table = hit.getTargetDataTable();
3910
+ if (id >= 0 === false || !table) return null;
3911
+ // add dx and dy properties, if not available
3912
+ if (!table.fieldExists('dx')) {
3913
+ table.addField('dx', 0);
3914
+ }
3915
+ if (!table.fieldExists('dy')) {
3916
+ table.addField('dy', 0);
3917
+ }
3918
+ if (!table.fieldExists('text-anchor')) {
3919
+ table.addField('text-anchor', '');
3920
+ }
3921
+ return table.getRecordAt(id);
3863
3922
  }
3864
- }
3865
3923
 
3866
- // Calling with a delay lets users see the menu update after clicking a selection,
3867
- // and prevents the menu from closing immediately if the pointer briefly drifts
3868
- // off the menu while hovering.
3869
- //
3870
- function closeMenu(delay) {
3871
- if (!_menuOpen) return;
3872
- clearTimeout(_menuTimeout);
3873
- _menuTimeout = setTimeout(function() {
3874
- _menuOpen = false;
3875
- updateArrowButton();
3876
- }, delay || 0);
3877
- }
3924
+ function onLabelDragStart(e) {
3925
+ var textNode = getTextTarget3(e);
3926
+ var table = hit.getTargetDataTable();
3927
+ if (!textNode || !table) return false;
3928
+ activeId = e.id;
3929
+ activeRecord = getLabelRecordById(activeId);
3930
+ downEvt = e;
3931
+ return true;
3932
+ }
3878
3933
 
3879
- function setMode(mode) {
3880
- var changed = mode != _editMode;
3881
- if (changed) {
3882
- menu.classed('active', mode != 'off');
3883
- _editMode = mode;
3884
- onModeChange();
3885
- updateArrowButton();
3886
- updateSelectionHighlight();
3934
+ function onLabelDrag(e) {
3935
+ var scale = ext.getSymbolScale() || 1;
3936
+ var textNode;
3937
+ if (!dragging) return;
3938
+ if (e.id != activeId) {
3939
+ error("Mismatched hit ids:", e.id, activeId);
3940
+ }
3941
+ applyDelta(activeRecord, 'dx', e.dx / scale);
3942
+ applyDelta(activeRecord, 'dy', e.dy / scale);
3943
+ textNode = getTextTarget3(e);
3944
+ if (!isMultilineLabel(textNode)) {
3945
+ // update anchor position of single-line labels based on label position
3946
+ // relative to anchor point, for better placement when eventual display font is
3947
+ // different from mapshaper's font.
3948
+ autoUpdateTextAnchor(textNode, activeRecord, getDisplayCoordsById(activeId, hit.getHitTarget().layer, ext));
3949
+ }
3950
+ // updateSymbol(targetTextNode, activeRecord);
3951
+ updateSymbol2(textNode, activeRecord, activeId);
3952
+ }
3953
+
3954
+ function getSymbolNodeById(id, parent) {
3955
+ // TODO: optimize selector
3956
+ var sel = '[data-id="' + id + '"]';
3957
+ return parent.querySelector(sel);
3958
+ }
3959
+
3960
+ function getTextTarget3(e) {
3961
+ if (e.id > -1 === false || !e.container) return null;
3962
+ return getSymbolNodeById(e.id, e.container);
3963
+ }
3964
+
3965
+ function getTextTarget2(e) {
3966
+ var el = e && e.targetSymbol || null;
3967
+ if (el && el.tagName == 'tspan') {
3968
+ el = el.parentNode;
3969
+ }
3970
+ return el && el.tagName == 'text' ? el : null;
3971
+ }
3972
+
3973
+ function getTextTarget(e) {
3974
+ var el = e.target;
3975
+ if (el.tagName == 'tspan') {
3976
+ el = el.parentNode;
3977
+ }
3978
+ return el.tagName == 'text' ? el : null;
3887
3979
  }
3980
+
3981
+ // svg.addEventListener('mousedown', function(e) {
3982
+ // var textTarget = getTextTarget(e);
3983
+ // downEvt = e;
3984
+ // if (!textTarget) {
3985
+ // stopEditing();
3986
+ // } else if (!editing) {
3987
+ // // nop
3988
+ // } else if (textTarget == targetTextNode) {
3989
+ // startDragging();
3990
+ // } else {
3991
+ // startDragging();
3992
+ // editTextNode(textTarget);
3993
+ // }
3994
+ // });
3995
+
3996
+ // up event on svg
3997
+ // a: currently dragging text
3998
+ // -> stop dragging
3999
+ // b: clicked on a text feature
4000
+ // -> start editing it
4001
+
4002
+
4003
+ // svg.addEventListener('mouseup', function(e) {
4004
+ // var textTarget = getTextTarget(e);
4005
+ // var isClick = isClickEvent(e, downEvt);
4006
+ // if (isClick && textTarget && textTarget == targetTextNode &&
4007
+ // activeRecord && isMultilineLabel(targetTextNode)) {
4008
+ // toggleTextAlign(targetTextNode, activeRecord);
4009
+ // updateSymbol();
4010
+ // }
4011
+ // if (dragging) {
4012
+ // stopDragging();
4013
+ // } else if (isClick && textTarget) {
4014
+ // editTextNode(textTarget);
4015
+ // }
4016
+ // });
4017
+
4018
+ // block dbl-click navigation when editing
4019
+ // mouse.on('dblclick', function(e) {
4020
+ // if (editing) e.stopPropagation();
4021
+ // }, null, eventPriority);
4022
+
4023
+ // mouse.on('dragstart', function(e) {
4024
+ // onLabelDrag(e);
4025
+ // }, null, eventPriority);
4026
+
4027
+ // mouse.on('drag', function(e) {
4028
+ // var scale = ext.getSymbolScale() || 1;
4029
+ // onLabelDrag(e);
4030
+ // if (!dragging || !activeRecord) return;
4031
+ // applyDelta(activeRecord, 'dx', e.dx / scale);
4032
+ // applyDelta(activeRecord, 'dy', e.dy / scale);
4033
+ // if (!isMultilineLabel(targetTextNode)) {
4034
+ // // update anchor position of single-line labels based on label position
4035
+ // // relative to anchor point, for better placement when eventual display font is
4036
+ // // different from mapshaper's font.
4037
+ // updateTextAnchor(targetTextNode, activeRecord);
4038
+ // }
4039
+ // // updateSymbol(targetTextNode, activeRecord);
4040
+ // targetTextNode = updateSymbol2(targetTextNode, activeRecord, activeId);
4041
+ // }, null, eventPriority);
4042
+
4043
+ // mouse.on('dragend', function(e) {
4044
+ // onLabelDrag(e);
4045
+ // stopDragging();
4046
+ // }, null, eventPriority);
4047
+
4048
+
4049
+ // function onLabelDrag(e) {
4050
+ // if (dragging) {
4051
+ // e.stopPropagation();
4052
+ // }
4053
+ // }
3888
4054
  }
3889
4055
 
3890
- function onModeChange() {
3891
- gui.dispatchEvent('interaction_mode_change', {mode: getInteractionMode()});
4056
+ function startDragging() {
4057
+ dragging = true;
3892
4058
  }
3893
4059
 
3894
- // Update button highlight and selected menu item highlight (if any)
3895
- function updateArrowButton() {
3896
- if (!menu) return;
3897
- if (_menuOpen) {
3898
- btn.addClass('open');
3899
- } else {
3900
- btn.removeClass('open');
3901
- }
3902
- btn.classed('hover', _menuOpen);
3903
- // btn.classed('selected', active() && !_menuOpen);
3904
- btn.classed('selected', active());
4060
+ function stopDragging() {
4061
+ dragging = false;
4062
+ activeId = -1;
4063
+ activeRecord = null;
4064
+ activeVertexIds = null;
3905
4065
  }
3906
4066
 
3907
- function updateSelectionHighlight() {
3908
- El.findAll('.nav-menu-item').forEach(function(el) {
3909
- el = El(el);
3910
- el.classed('selected', el.attr('data-name') == _editMode);
3911
- });
4067
+ function isClickEvent(up, down) {
4068
+ var elapsed = Math.abs(down.timeStamp - up.timeStamp);
4069
+ var dx = up.screenX - down.screenX;
4070
+ var dy = up.screenY - down.screenY;
4071
+ var dist = Math.sqrt(dx * dx + dy * dy);
4072
+ return dist <= 4 && elapsed < 300;
3912
4073
  }
4074
+
3913
4075
  }
3914
4076
 
3915
- function Model(gui) {
3916
- var self = new internal.Catalog();
3917
- var deleteLayer = self.deleteLayer;
3918
- utils.extend(self, EventDispatcher.prototype);
4077
+ // import { cloneShape } from '../paths/mapshaper-shape-utils';
4078
+ // import { copyRecord } from '../datatable/mapshaper-data-utils';
4079
+ var cloneShape = internal.cloneShape;
4080
+ var copyRecord = internal.copyRecord;
3919
4081
 
3920
- // override Catalog method (so -drop command will work in web console)
3921
- self.deleteLayer = function(lyr, dataset) {
3922
- var active, flags;
3923
- deleteLayer.call(self, lyr, dataset);
3924
- if (self.isEmpty()) {
3925
- // refresh browser if deleted layer was the last layer
3926
- window.location.href = window.location.href.toString();
3927
- } else {
3928
- // trigger event to update layer list and, if needed, the map view
3929
- flags = {};
3930
- active = self.getActiveLayer();
3931
- if (active.layer != lyr) {
3932
- flags.select = true;
3933
- }
3934
- internal.cleanupArcs(active.dataset);
3935
- if (internal.layerHasPaths(lyr)) {
3936
- flags.arc_count = true; // looks like a kludge, try to remove
3937
- }
3938
- self.updated(flags, active.layer, active.dataset);
3939
- }
3940
- };
4082
+ function Undo(gui) {
4083
+ var history, offset, stashedUndo;
4084
+ reset();
3941
4085
 
3942
- self.updated = function(flags) {
3943
- var targets = self.getDefaultTargets();
3944
- var active = self.getActiveLayer();
3945
- if (internal.countTargetLayers(targets) > 1) {
3946
- self.setDefaultTarget([active.layer], active.dataset);
3947
- gui.session.setTargetLayer(active.layer); // add -target command to target single layer
4086
+ function reset() {
4087
+ history = [];
4088
+ stashedUndo = null;
4089
+ offset = 0;
4090
+ }
4091
+
4092
+ function refreshMap() {
4093
+ gui.dispatchEvent('undo_redo');
4094
+ }
4095
+
4096
+ function isUndoEvt(e) {
4097
+ return (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key == 'z';
4098
+ }
4099
+
4100
+ function isRedoEvt(e) {
4101
+ return (e.ctrlKey || e.metaKey) && (e.shiftKey && e.key == 'z' || !e.shiftKey && e.key == 'y');
4102
+ }
4103
+
4104
+ gui.keyboard.on('keydown', function(evt) {
4105
+ var e = evt.originalEvent,
4106
+ kc = e.keyCode;
4107
+ if (isUndoEvt(e)) {
4108
+ this.undo();
4109
+ e.stopPropagation();
4110
+ e.preventDefault();
3948
4111
  }
3949
- if (flags.select) {
3950
- self.dispatchEvent('select', active);
4112
+ if (isRedoEvt(e)) {
4113
+ this.redo();
4114
+ e.stopPropagation();
4115
+ e.preventDefault();
3951
4116
  }
3952
- self.dispatchEvent('update', utils.extend({flags: flags}, active));
4117
+
4118
+ }, this, 10);
4119
+
4120
+ // undo/redo point/symbol dragging
4121
+ //
4122
+ gui.on('symbol_dragstart', function(e) {
4123
+ stashedUndo = this.makePointSetter(e.FID);
4124
+ }, this);
4125
+
4126
+ gui.on('symbol_dragend', function(e) {
4127
+ var redo = this.makePointSetter(e.FID);
4128
+ this.addHistoryState(stashedUndo, redo);
4129
+ }, this);
4130
+
4131
+ // undo/redo label dragging
4132
+ //
4133
+ gui.on('label_dragstart', function(e) {
4134
+ stashedUndo = this.makeDataSetter(e.FID);
4135
+ }, this);
4136
+
4137
+ gui.on('label_dragend', function(e) {
4138
+ var redo = this.makeDataSetter(e.FID);
4139
+ this.addHistoryState(stashedUndo, redo);
4140
+ }, this);
4141
+
4142
+ // undo/redo data editing
4143
+ // TODO: consider setting selected feature to the undo/redo target feature
4144
+ //
4145
+ gui.on('data_preupdate', function(e) {
4146
+ stashedUndo = this.makeDataSetter(e.FID);
4147
+ }, this);
4148
+
4149
+ gui.on('data_postupdate', function(e) {
4150
+ var redo = this.makeDataSetter(e.FID);
4151
+ this.addHistoryState(stashedUndo, redo);
4152
+ }, this);
4153
+
4154
+ // undo/redo vertex dragging
4155
+ gui.on('vertex_dragstart', function(e) {
4156
+ stashedUndo = this.makeVertexSetter(e.FID, e.vertex_ids);
4157
+ }, this);
4158
+
4159
+ gui.on('vertex_dragend', function(e) {
4160
+ var redo = this.makeVertexSetter(e.FID, e.vertex_ids);
4161
+ this.addHistoryState(stashedUndo, redo);
4162
+ }, this);
4163
+
4164
+ this.clear = function() {
4165
+ reset();
3953
4166
  };
3954
4167
 
3955
- self.selectLayer = function(lyr, dataset) {
3956
- if (self.getActiveLayer().layer == lyr) return;
3957
- self.setDefaultTarget([lyr], dataset);
3958
- self.updated({select: true});
3959
- gui.session.setTargetLayer(lyr);
4168
+ this.makePointSetter = function(i) {
4169
+ var target = gui.model.getActiveLayer();
4170
+ var shp = cloneShape(target.layer.shapes[i]);
4171
+ return function() {
4172
+ target.layer.shapes[i] = shp;
4173
+ };
3960
4174
  };
3961
4175
 
3962
- self.selectNextLayer = function() {
3963
- var next = self.findNextLayer(self.getActiveLayer().layer);
3964
- if (next) self.selectLayer(next.layer, next.dataset);
4176
+ this.makeDataSetter = function(id) {
4177
+ var target = gui.model.getActiveLayer();
4178
+ var rec = copyRecord(target.layer.data.getRecordAt(id));
4179
+ return function() {
4180
+ target.layer.data.getRecords()[id] = rec;
4181
+ gui.dispatchEvent('popup-needs-refresh');
4182
+ };
3965
4183
  };
3966
4184
 
3967
- self.selectPrevLayer = function() {
3968
- var prev = self.findPrevLayer(self.getActiveLayer().layer);
3969
- if (prev) self.selectLayer(prev.layer, prev.dataset);
4185
+ this.makeVertexSetter = function(fid, ids) {
4186
+ var target = gui.model.getActiveLayer();
4187
+ var arcs = target.dataset.arcs;
4188
+ var p = arcs.getVertex2(ids[0]);
4189
+ return function() {
4190
+ snapVerticesToPoint(ids, p, arcs, true);
4191
+ };
3970
4192
  };
3971
4193
 
3972
- return self;
3973
- }
4194
+ this.addHistoryState = function(undo, redo) {
4195
+ if (offset > 0) {
4196
+ history.splice(-offset);
4197
+ offset = 0;
4198
+ }
4199
+ history.push({undo, redo});
4200
+ };
3974
4201
 
3975
- function getShapeHitTest(displayLayer, ext) {
3976
- var geoType = displayLayer.layer.geometry_type;
3977
- var test;
3978
- if (geoType == 'point' && displayLayer.style.type == 'styled') {
3979
- test = getGraduatedCircleTest(getRadiusFunction(displayLayer.style));
3980
- } else if (geoType == 'point') {
3981
- test = pointTest;
3982
- } else if (geoType == 'polyline') {
3983
- test = polylineTest;
3984
- } else if (geoType == 'polygon') {
3985
- test = polygonTest;
3986
- } else {
3987
- error("Unexpected geometry type:", geoType);
4202
+ this.undo = function() {
4203
+ var item = getHistoryItem();
4204
+ if (item) {
4205
+ offset++;
4206
+ item.undo();
4207
+ refreshMap();
4208
+ }
4209
+ };
4210
+
4211
+ this.redo = function() {
4212
+ if (offset <= 0) return;
4213
+ offset--;
4214
+ var item = getHistoryItem();
4215
+ item.redo();
4216
+ refreshMap();
4217
+ };
4218
+
4219
+ function getHistoryItem() {
4220
+ var item = history[history.length - offset - 1];
4221
+ return item || null;
3988
4222
  }
3989
- return test;
3990
4223
 
3991
- // Convert pixel distance to distance in coordinate units.
3992
- function getHitBuffer(pix) {
3993
- return pix / ext.getTransform().mx;
3994
- }
3995
-
3996
- // reduce hit threshold when zoomed out
3997
- function getZoomAdjustedHitBuffer(pix, minPix) {
3998
- var scale = ext.scale();
3999
- if (scale < 1) {
4000
- pix *= scale;
4001
- }
4002
- if (minPix > 0 && pix < minPix) pix = minPix;
4003
- return getHitBuffer(pix);
4004
- }
4005
-
4006
- function polygonTest(x, y) {
4007
- var maxDist = getZoomAdjustedHitBuffer(5, 1),
4008
- cands = findHitCandidates(x, y, maxDist),
4009
- hits = [],
4010
- cand, hitId;
4011
- for (var i=0; i<cands.length; i++) {
4012
- cand = cands[i];
4013
- if (geom.testPointInPolygon(x, y, cand.shape, displayLayer.arcs)) {
4014
- hits.push(cand.id);
4015
- }
4016
- }
4017
- if (cands.length > 0 && hits.length === 0) {
4018
- // secondary detection: proximity, if not inside a polygon
4019
- sortByDistance(x, y, cands, displayLayer.arcs);
4020
- hits = pickNearestCandidates(cands, 0, maxDist);
4021
- }
4022
- return hits;
4023
- }
4224
+ }
4024
4225
 
4025
- function pickNearestCandidates(sorted, bufDist, maxDist) {
4026
- var hits = [],
4027
- cand, minDist;
4028
- for (var i=0; i<sorted.length; i++) {
4029
- cand = sorted[i];
4030
- if (cand.dist < maxDist !== true) {
4031
- break;
4032
- } else if (i === 0) {
4033
- minDist = cand.dist;
4034
- } else if (cand.dist - minDist > bufDist) {
4035
- break;
4036
- }
4037
- hits.push(cand.id);
4038
- }
4039
- return hits;
4040
- }
4226
+ function SidebarButtons(gui) {
4227
+ var root = gui.container.findChild('.mshp-main-map');
4228
+ var buttons = El('div').addClass('nav-buttons').appendTo(root).hide();
4229
+ var _hidden = true;
4230
+ gui.on('active', updateVisibility);
4231
+ gui.on('inactive', updateVisibility);
4041
4232
 
4042
- function polylineTest(x, y) {
4043
- var maxDist = getZoomAdjustedHitBuffer(15, 2),
4044
- bufDist = getZoomAdjustedHitBuffer(0.05), // tiny threshold for hitting almost-identical lines
4045
- cands = findHitCandidates(x, y, maxDist);
4046
- sortByDistance(x, y, cands, displayLayer.arcs);
4047
- return pickNearestCandidates(cands, bufDist, maxDist);
4048
- }
4233
+ // @iconRef: selector for an (svg) button icon
4234
+ this.addButton = function(iconRef) {
4235
+ var btn = initButton(iconRef).addClass('nav-btn');
4236
+ btn.appendTo(buttons);
4237
+ return btn;
4238
+ };
4049
4239
 
4050
- function sortByDistance(x, y, cands, arcs) {
4051
- for (var i=0; i<cands.length; i++) {
4052
- cands[i].dist = geom.getPointToShapeDistance(x, y, cands[i].shape, arcs);
4053
- }
4054
- utils.sortOn(cands, 'dist');
4055
- }
4240
+ this.show = function() {
4241
+ _hidden = false;
4242
+ updateVisibility();
4243
+ };
4056
4244
 
4057
- function pointTest(x, y) {
4058
- var bullseyeDist = 2, // hit all points w/in 2 px
4059
- tinyDist = 0.5,
4060
- toPx = ext.getTransform().mx,
4061
- hits = [],
4062
- hitThreshold = 25,
4063
- newThreshold = Infinity;
4245
+ this.hide = function() {
4246
+ _hidden = true;
4247
+ updateVisibility();
4248
+ };
4064
4249
 
4065
- internal.forEachPoint(displayLayer.layer.shapes, function(p, id) {
4066
- var dist = geom.distance2D(x, y, p[0], p[1]) * toPx;
4067
- if (dist > hitThreshold) return;
4068
- // got a hit
4069
- if (dist < newThreshold) {
4070
- // start a collection of hits
4071
- hits = [id];
4072
- hitThreshold = Math.max(bullseyeDist, dist + tinyDist);
4073
- newThreshold = dist < bullseyeDist ? -1 : dist - tinyDist;
4074
- } else {
4075
- // add to hits if inside bullseye or is same dist as previous hit
4076
- hits.push(id);
4077
- }
4078
- });
4079
- // console.log(hitThreshold, bullseye);
4080
- return utils.uniq(hits); // multipoint features can register multiple hits
4081
- }
4250
+ var initButton = this.initButton = function(iconRef) {
4251
+ var icon = El('body').findChild(iconRef).node().cloneNode(true);
4252
+ var btn = El('div')
4253
+ .on('dblclick', function(e) {e.stopPropagation();}); // block dblclick zoom
4254
+ btn.appendChild(icon);
4255
+ if (icon.hasAttribute('id')) icon.removeAttribute('id');
4256
+ return btn;
4257
+ };
4082
4258
 
4083
- function getRadiusFunction(style) {
4084
- var o = {};
4085
- if (style.styler) {
4086
- return function(i) {
4087
- style.styler(o, i);
4088
- return o.radius || 0;
4089
- };
4259
+ function updateVisibility() {
4260
+ if (GUI.isActiveInstance(gui) && !_hidden) {
4261
+ buttons.show();
4262
+ } else {
4263
+ buttons.hide();
4090
4264
  }
4091
- return function() {return style.radius || 0;};
4092
- }
4093
-
4094
- function getGraduatedCircleTest(radius) {
4095
- return function(x, y) {
4096
- var hits = [],
4097
- margin = getHitBuffer(12),
4098
- limit = getHitBuffer(50), // short-circuit hit test beyond this threshold
4099
- directHit = false,
4100
- hitRadius = 0,
4101
- hitDist;
4102
- internal.forEachPoint(displayLayer.layer.shapes, function(p, id) {
4103
- var distSq = geom.distanceSq(x, y, p[0], p[1]);
4104
- var isHit = false;
4105
- var isOver, isNear, r, d, rpix;
4106
- if (distSq > limit * limit) return;
4107
- rpix = radius(id);
4108
- r = getHitBuffer(rpix + 1); // increase effective radius to make small bubbles easier to hit in clusters
4109
- d = Math.sqrt(distSq) - r; // pointer distance from edge of circle (negative = inside)
4110
- isOver = d < 0;
4111
- isNear = d < margin;
4112
- if (!isNear || rpix > 0 === false) {
4113
- isHit = false;
4114
- } else if (hits.length === 0) {
4115
- isHit = isNear;
4116
- } else if (!directHit && isOver) {
4117
- isHit = true;
4118
- } else if (directHit && isOver) {
4119
- isHit = r == hitRadius ? d <= hitDist : r < hitRadius; // smallest bubble wins if multiple direct hits
4120
- } else if (!directHit && !isOver) {
4121
- // closest to bubble edge wins
4122
- isHit = hitDist == d ? r <= hitRadius : d < hitDist; // closest bubble wins if multiple indirect hits
4123
- }
4124
- if (isHit) {
4125
- if (hits.length > 0 && (r != hitRadius || d != hitDist)) {
4126
- hits = [];
4127
- }
4128
- hitRadius = r;
4129
- hitDist = d;
4130
- directHit = isOver;
4131
- hits.push(id);
4132
- }
4133
- });
4134
- return hits;
4135
- };
4136
- }
4137
-
4138
- function findHitCandidates(x, y, dist) {
4139
- var arcs = displayLayer.arcs,
4140
- index = {},
4141
- cands = [],
4142
- bbox = [];
4143
- displayLayer.layer.shapes.forEach(function(shp, shpId) {
4144
- var cand;
4145
- for (var i = 0, n = shp && shp.length; i < n; i++) {
4146
- arcs.getSimpleShapeBounds2(shp[i], bbox);
4147
- if (x + dist < bbox[0] || x - dist > bbox[2] ||
4148
- y + dist < bbox[1] || y - dist > bbox[3]) {
4149
- continue; // bbox non-intersection
4150
- }
4151
- cand = index[shpId];
4152
- if (!cand) {
4153
- cand = index[shpId] = {shape: [], id: shpId, dist: 0};
4154
- cands.push(cand);
4155
- }
4156
- cand.shape.push(shp[i]);
4157
- }
4158
- });
4159
- return cands;
4160
4265
  }
4161
4266
  }
4162
4267
 
4163
- function getSymbolNodeId(node) {
4164
- return parseInt(node.getAttribute('data-id'));
4165
- }
4166
-
4167
- function getSvgSymbolTransform(xy, ext) {
4168
- var scale = ext.getSymbolScale();
4169
- var p = ext.translateCoords(xy[0], xy[1]);
4170
- return internal.svg.getTransform(p, scale);
4171
- }
4172
-
4173
-
4174
- function repositionSymbols(elements, layer, ext) {
4175
- var el, idx, shp, p, displayOn, inView, displayBounds;
4176
- for (var i=0, n=elements.length; i<n; i++) {
4177
- el = elements[i];
4178
- idx = getSymbolNodeId(el);
4179
- shp = layer.shapes[idx];
4180
- if (!shp) continue;
4181
- p = shp[0];
4182
- // OPTIMIZATION: only display symbols that are in view
4183
- // quick-and-dirty hit-test: expand the extent rectangle by a percentage.
4184
- // very large symbols will disappear before they're completely out of view
4185
- displayBounds = ext.getBounds(1.15);
4186
- displayOn = !el.hasAttribute('display') || el.getAttribute('display') == 'block';
4187
- inView = displayBounds.containsPoint(p[0], p[1]);
4188
- if (inView) {
4189
- if (!displayOn) el.setAttribute('display', 'block');
4190
- el.setAttribute('transform', getSvgSymbolTransform(p, ext));
4268
+ function ModeButton(modes, el, name) {
4269
+ var btn = El(el),
4270
+ active = false;
4271
+ modes.on('mode', function(e) {
4272
+ active = e.name == name;
4273
+ if (active) {
4274
+ btn.addClass('active');
4191
4275
  } else {
4192
- if (displayOn) el.setAttribute('display', 'none');
4276
+ btn.removeClass('active');
4193
4277
  }
4194
- }
4195
- }
4196
-
4197
- function renderSymbols(lyr, ext, type) {
4198
- var records = lyr.data.getRecords();
4199
- var symbols = lyr.shapes.map(function(shp, i) {
4200
- var d = records[i];
4201
- var obj = type == 'label' ? internal.svg.importStyledLabel(d) :
4202
- internal.svg.importSymbol(d['svg-symbol']);
4203
- if (!obj || !shp) return null;
4204
- obj.properties.transform = getSvgSymbolTransform(shp[0], ext);
4205
- obj.properties['data-id'] = i;
4206
- return obj;
4207
4278
  });
4208
- var obj = internal.getEmptyLayerForSVG(lyr, {});
4209
- obj.children = symbols;
4210
- return internal.svg.stringify(obj);
4211
- }
4212
4279
 
4213
- function getSvgHitTest(displayLayer) {
4214
-
4215
- return function(pointerEvent) {
4216
- // target could be a part of an SVG symbol, or the SVG element, or something else
4217
- var target = pointerEvent.originalEvent.target;
4218
- var symbolNode = getSymbolNode(target);
4219
- if (!symbolNode) {
4220
- return null;
4221
- }
4222
- return {
4223
- targetId: getSymbolNodeId(symbolNode), // TODO: some validation on id
4224
- targetSymbol: symbolNode,
4225
- targetNode: target,
4226
- container: symbolNode.parentNode
4227
- };
4228
- };
4229
-
4230
- // target: event target (could be any DOM element)
4231
- function getSymbolNode(target) {
4232
- var node = target;
4233
- while (node && nodeHasSymbolTagType(node)) {
4234
- if (isSymbolNode(node)) {
4235
- return node;
4236
- }
4237
- node = node.parentElement;
4238
- }
4239
- return null;
4240
- }
4241
-
4242
- // TODO: switch to attribute detection
4243
- function nodeHasSymbolTagType(node) {
4244
- var tag = node.tagName;
4245
- return tag == 'g' || tag == 'tspan' || tag == 'text' || tag == 'image' ||
4246
- tag == 'path' || tag == 'circle' || tag == 'rect' || tag == 'line';
4247
- }
4248
-
4249
- function isSymbolNode(node) {
4250
- return node.hasAttribute('data-id') && (node.tagName == 'text' || node.tagName == 'g');
4251
- }
4252
-
4253
- function isSymbolChildNode(node) {
4254
-
4255
- }
4256
-
4257
- function getChildId(childNode) {
4258
-
4259
- }
4260
-
4261
- function getSymbolId(symbolNode) {
4262
-
4263
- }
4264
-
4265
- function getFeatureId(symbolNode) {
4266
-
4267
- }
4268
-
4269
- }
4270
-
4271
- function getPointerHitTest(mapLayer, ext) {
4272
- var shapeTest, svgTest, targetLayer;
4273
- if (!mapLayer || !internal.layerHasGeometry(mapLayer.layer)) {
4274
- return null;
4275
- }
4276
- shapeTest = getShapeHitTest(mapLayer, ext);
4277
- svgTest = getSvgHitTest(mapLayer);
4278
-
4279
- // e: pointer event
4280
- return function(e) {
4281
- var p = ext.translatePixelCoords(e.x, e.y);
4282
- var data = {
4283
- ids: shapeTest(p[0], p[1]) || []
4284
- };
4285
- var svgData = svgTest(e); // null or a data object
4286
- if (svgData) { // mouse is over an SVG symbol
4287
- utils.extend(data, svgData);
4288
- // placing symbol id in front of any other hits
4289
- data.ids = utils.uniq([svgData.targetId].concat(data.ids));
4290
- }
4291
- data.id = data.ids.length > 0 ? data.ids[0] : -1;
4292
- return data;
4293
- };
4280
+ btn.on('click', function() {
4281
+ modes.enterMode(active ? null : name);
4282
+ });
4294
4283
  }
4295
4284
 
4296
- function InteractiveSelection(gui, ext, mouse) {
4297
- var self = new EventDispatcher();
4298
- var storedData = noHitData(); // may include additional data from SVG symbol hit (e.g. hit node)
4299
- var selectionIds = [];
4300
- var transientIds = []; // e.g. hit ids while dragging a box
4301
- var active = false;
4302
- var interactionMode;
4303
- var targetLayer;
4304
- var hitTest;
4305
- // event priority is higher than navigation, so stopping propagation disables
4306
- // pan navigation
4307
- var priority = 2;
4308
-
4309
- // init keyboard controls for pinned features
4310
- gui.keyboard.on('keydown', function(evt) {
4311
- var e = evt.originalEvent;
4312
-
4313
- if (gui.interaction.getMode() == 'off' || !targetLayer) return;
4314
-
4315
- // esc key clears selection (unless in an editing mode -- esc key also exits current mode)
4316
- if (e.keyCode == 27 && !gui.getMode()) {
4317
- self.clearSelection();
4318
- return;
4319
- }
4320
-
4321
- // ignore keypress if no feature is selected or user is editing text
4322
- if (pinnedId() == -1 || GUI.getInputElement()) return;
4323
-
4324
- if (e.keyCode == 37 || e.keyCode == 39) {
4325
- // L/R arrow keys
4326
- // advance pinned feature
4327
- advanceSelectedFeature(e.keyCode == 37 ? -1 : 1);
4328
- e.stopPropagation();
4285
+ function ModeSwitcher() {
4286
+ var self = this;
4287
+ var mode = null;
4329
4288
 
4330
- } else if (e.keyCode == 8) {
4331
- // DELETE key
4332
- // delete pinned feature
4333
- // to help protect against inadvertent deletion, don't delete
4334
- // when console is open or a popup menu is open
4335
- if (!gui.getMode() && !gui.consoleIsOpen()) {
4336
- internal.deleteFeatureById(targetLayer.layer, pinnedId());
4337
- self.clearSelection();
4338
- gui.model.updated({flags: 'filter'}); // signal map to update
4289
+ self.getMode = function() {
4290
+ return mode;
4291
+ };
4292
+
4293
+ // return a function to trigger this mode
4294
+ self.addMode = function(name, enter, exit, btn) {
4295
+ self.on('mode', function(e) {
4296
+ if (e.prev == name) {
4297
+ exit();
4298
+ }
4299
+ if (e.name == name) {
4300
+ enter();
4339
4301
  }
4302
+ });
4303
+ if (btn) {
4304
+ new ModeButton(self, btn, name);
4340
4305
  }
4341
- }, !!'capture'); // preempt the layer control's arrow key handler
4306
+ };
4342
4307
 
4343
- self.setLayer = function(mapLayer) {
4344
- hitTest = getPointerHitTest(mapLayer, ext);
4345
- if (!hitTest) {
4346
- hitTest = function() {return {ids: []};};
4308
+ self.addMode(null, function() {}, function() {}); // null mode
4309
+
4310
+ self.clearMode = function() {
4311
+ self.enterMode(null);
4312
+ };
4313
+
4314
+ self.enterMode = function(next) {
4315
+ var prev = mode;
4316
+ if (next != prev) {
4317
+ mode = next;
4318
+ self.dispatchEvent('mode', {name: next, prev: prev});
4347
4319
  }
4348
- targetLayer = mapLayer;
4349
4320
  };
4321
+ }
4350
4322
 
4351
- function turnOn(mode) {
4352
- interactionMode = mode;
4353
- active = true;
4354
- }
4323
+ utils.inherit(ModeSwitcher, EventDispatcher);
4355
4324
 
4356
- function turnOff() {
4357
- if (active) {
4358
- updateSelectionState(null); // no hit data, no event
4359
- active = false;
4360
- }
4361
- }
4325
+ function KeyboardEvents(gui) {
4326
+ var self = this;
4327
+ var shiftDown = false;
4328
+ document.addEventListener('keyup', function(e) {
4329
+ if (!GUI.isActiveInstance(gui)) return;
4330
+ if (e.keyCode == 16) shiftDown = false;
4331
+ });
4362
4332
 
4363
- function selectable() {
4364
- return interactionMode == 'selection';
4365
- }
4333
+ document.addEventListener('keydown', function(e) {
4334
+ if (!GUI.isActiveInstance(gui)) return;
4335
+ if (e.keyCode == 16) shiftDown = true;
4336
+ self.dispatchEvent('keydown', {originalEvent: e});
4337
+ });
4366
4338
 
4367
- function pinnable() {
4368
- return clickable() && interactionMode != 'selection';
4369
- }
4339
+ this.shiftIsPressed = function() { return shiftDown; };
4370
4340
 
4371
- function draggable() {
4372
- return interactionMode == 'vertices' || interactionMode == 'location' || interactionMode == 'labels';
4373
- }
4341
+ this.onMenuSubmit = function(menuEl, cb) {
4342
+ gui.on('enter_key', function(e) {
4343
+ if (menuEl.visible()) {
4344
+ e.originalEvent.stopPropagation();
4345
+ cb();
4346
+ }
4347
+ });
4348
+ };
4349
+ }
4374
4350
 
4375
- function clickable() {
4376
- // click used to pin popup and select features
4377
- return interactionMode == 'data' || interactionMode == 'info' || interactionMode == 'selection';
4378
- }
4351
+ utils.inherit(KeyboardEvents, EventDispatcher);
4379
4352
 
4380
- self.getHitId = function() {return storedData.id;};
4353
+ function InteractionMode(gui) {
4381
4354
 
4382
- // Get a reference to the active layer, so listeners to hit events can interact
4383
- // with data and shapes
4384
- self.getHitTarget = function() {
4385
- return targetLayer;
4355
+ var menus = {
4356
+ standard: ['info', 'selection', 'data', 'box'],
4357
+ lines: ['info', 'selection', 'data', 'box', 'vertices'],
4358
+ table: ['info', 'selection', 'data'],
4359
+ labels: ['info', 'selection', 'data', 'box', 'labels', 'location'],
4360
+ points: ['info', 'selection', 'data', 'box', 'location']
4386
4361
  };
4387
4362
 
4388
- self.addSelectionIds = function(ids) {
4389
- turnOn('selection');
4390
- selectionIds = utils.uniq(selectionIds.concat(ids));
4391
- ids = utils.uniq(storedData.ids.concat(ids));
4392
- updateSelectionState({ids: ids});
4363
+ var prompts = {
4364
+ box: 'Shift-drag to draw a box',
4365
+ data: 'Click-select features to edit their attributes',
4366
+ selection: 'Click-select or shift-drag to select features'
4393
4367
  };
4394
4368
 
4395
- self.setTransientIds = function(ids) {
4396
- // turnOn('selection');
4397
- transientIds = ids || [];
4398
- if (active) {
4399
- triggerHitEvent('change');
4400
- }
4369
+ // mode name -> menu text lookup
4370
+ var labels = {
4371
+ info: 'inspect features',
4372
+ box: 'shift-drag box tool',
4373
+ data: 'edit attributes',
4374
+ labels: 'position labels',
4375
+ location: 'drag points',
4376
+ vertices: 'drag vertices',
4377
+ selection: 'select features',
4378
+ off: 'turn off'
4401
4379
  };
4380
+ var btn, menu;
4381
+ var _menuTimeout;
4402
4382
 
4403
- self.clearSelection = function() {
4404
- updateSelectionState(null);
4405
- };
4383
+ // state variables
4384
+ var _editMode = 'off';
4385
+ var _menuOpen = false;
4406
4386
 
4407
- self.clearHover = function() {
4408
- updateSelectionState(mergeHoverData({ids: []}));
4409
- };
4387
+ // Only render edit mode button/menu if this option is present
4388
+ if (gui.options.inspectorControl) {
4389
+ btn = gui.buttons.addButton('#pointer-icon').addClass('menu-btn');
4390
+ menu = El('div').addClass('nav-sub-menu').appendTo(btn.node());
4410
4391
 
4411
- self.getSelectionIds = function() {
4412
- return selectionIds.concat();
4413
- };
4392
+ btn.on('mouseleave', function() {
4393
+ if (!_menuOpen) {
4394
+ btn.removeClass('hover');
4395
+ } else {
4396
+ closeMenu(200);
4397
+ }
4398
+ });
4414
4399
 
4415
- self.getTargetDataTable = function() {
4416
- var targ = self.getHitTarget();
4417
- return targ && targ.layer.data || null;
4400
+ btn.on('mouseenter', function() {
4401
+ btn.addClass('hover');
4402
+ if (_menuOpen) {
4403
+ clearTimeout(_menuTimeout); // prevent timed closing
4404
+ } else {
4405
+ openMenu();
4406
+ }
4407
+ // if (_editMode != 'off') {
4408
+ // openMenu();
4409
+ // }
4410
+ });
4411
+
4412
+ btn.on('click', function(e) {
4413
+ if (active()) {
4414
+ setMode('off');
4415
+ closeMenu();
4416
+ } else if (_menuOpen) {
4417
+ setMode('info'); // select info (inspect) as the default
4418
+ // closeMenu(350);
4419
+ } else {
4420
+ openMenu();
4421
+ }
4422
+ e.stopPropagation();
4423
+ });
4424
+ }
4425
+
4426
+ this.turnOff = function() {
4427
+ setMode('off');
4418
4428
  };
4419
4429
 
4420
- // get function for selecting next or prev feature within the current set of
4421
- // selected features
4422
- self.getSwitchTrigger = function(diff) {
4423
- return function() {
4424
- switchWithinSelection(diff);
4425
- };
4430
+ this.getMode = getInteractionMode;
4431
+
4432
+ this.setMode = function(mode) {
4433
+ // TODO: check that this mode is valid for the current dataset
4434
+ if (mode in labels) {
4435
+ setMode(mode);
4436
+ }
4426
4437
  };
4427
4438
 
4428
- // diff: 1 or -1
4429
- function advanceSelectedFeature(diff) {
4430
- var n = internal.getFeatureCount(targetLayer.layer);
4431
- if (n < 2 || pinnedId() == -1) return;
4432
- storedData.id = (pinnedId() + n + diff) % n;
4433
- storedData.ids = [storedData.id];
4434
- triggerHitEvent('change');
4439
+ gui.model.on('update', function(e) {
4440
+ // change mode if active layer doesn't support the current mode
4441
+ updateCurrentMode();
4442
+ if (_menuOpen) {
4443
+ renderMenu();
4444
+ }
4445
+ }, null, -1); // low priority?
4446
+
4447
+ function active() {
4448
+ return _editMode && _editMode != 'off';
4435
4449
  }
4436
4450
 
4437
- // diff: 1 or -1
4438
- function switchWithinSelection(diff) {
4439
- var id = pinnedId();
4440
- var i = storedData.ids.indexOf(id);
4441
- var n = storedData.ids.length;
4442
- if (i < 0 || n < 2) return;
4443
- storedData.id = storedData.ids[(i + diff + n) % n];
4444
- triggerHitEvent('change');
4451
+ function getAvailableModes() {
4452
+ var o = gui.model.getActiveLayer();
4453
+ if (!o || !o.layer) {
4454
+ return menus.standard; // TODO: more sensible handling of missing layer
4455
+ }
4456
+ if (!internal.layerHasGeometry(o.layer)) {
4457
+ return menus.table;
4458
+ }
4459
+ if (internal.layerHasLabels(o.layer)) {
4460
+ return menus.labels;
4461
+ }
4462
+ if (internal.layerHasPoints(o.layer)) {
4463
+ return menus.points;
4464
+ }
4465
+ if (internal.layerHasPaths(o.layer) && o.layer.geometry_type == 'polyline') {
4466
+ return menus.lines;
4467
+ }
4468
+ return menus.standard;
4469
+ }
4470
+
4471
+ function getInteractionMode() {
4472
+ return active() ? _editMode : 'off';
4473
+ }
4474
+
4475
+ function renderMenu() {
4476
+ if (!menu) return;
4477
+ var modes = getAvailableModes();
4478
+ menu.empty();
4479
+ modes.forEach(function(mode) {
4480
+ // don't show "turn off" link if not currently editing
4481
+ if (_editMode == 'off' && mode == 'off') return;
4482
+ var link = El('div').addClass('nav-menu-item').attr('data-name', mode).text(labels[mode]).appendTo(menu);
4483
+ link.on('click', function(e) {
4484
+ if (_editMode == mode) {
4485
+ // closeMenu();
4486
+ setMode('off');
4487
+ } else if (_editMode != mode) {
4488
+ setMode(mode);
4489
+ if (mode == 'off') closeMenu(120); // only close if turning off
4490
+ // closeMenu(mode == 'off' ? 120 : 400); // close after selecting
4491
+ }
4492
+ e.stopPropagation();
4493
+ });
4494
+ });
4495
+ updateSelectionHighlight();
4445
4496
  }
4446
4497
 
4447
- // make sure popup is unpinned and turned off when switching editing modes
4448
- // (some modes do not support pinning)
4449
- gui.on('interaction_mode_change', function(e) {
4450
- updateSelectionState(null);
4451
- if (e.mode == 'off' || e.mode == 'box') {
4452
- turnOff();
4453
- } else {
4454
- turnOn(e.mode);
4498
+ // if current editing mode is not available, turn off the tool
4499
+ function updateCurrentMode() {
4500
+ var modes = getAvailableModes();
4501
+ if (modes.indexOf(_editMode) == -1) {
4502
+ setMode('off');
4455
4503
  }
4456
- });
4457
-
4458
- gui.on('box_drag_start', function() {
4459
- self.clearHover();
4460
- });
4504
+ }
4461
4505
 
4462
- mouse.on('dblclick', handlePointerEvent, null, priority);
4463
- mouse.on('dragstart', handlePointerEvent, null, priority);
4464
- mouse.on('drag', handlePointerEvent, null, priority);
4465
- mouse.on('dragend', handlePointerEvent, null, priority);
4506
+ function openMenu() {
4507
+ clearTimeout(_menuTimeout);
4508
+ if (!_menuOpen) {
4509
+ _menuOpen = true;
4510
+ renderMenu();
4511
+ updateArrowButton();
4512
+ }
4513
+ }
4466
4514
 
4467
- mouse.on('click', function(e) {
4468
- if (!hitTest || !active) return;
4469
- e.stopPropagation();
4515
+ // Calling with a delay lets users see the menu update after clicking a selection,
4516
+ // and prevents the menu from closing immediately if the pointer briefly drifts
4517
+ // off the menu while hovering.
4518
+ //
4519
+ function closeMenu(delay) {
4520
+ if (!_menuOpen) return;
4521
+ clearTimeout(_menuTimeout);
4522
+ _menuTimeout = setTimeout(function() {
4523
+ _menuOpen = false;
4524
+ updateArrowButton();
4525
+ }, delay || 0);
4526
+ }
4470
4527
 
4471
- // TODO: move pinning to inspection control?
4472
- if (clickable()) {
4473
- updateSelectionState(mergeClickData(hitTest(e)));
4528
+ function setMode(mode) {
4529
+ var changed = mode != _editMode;
4530
+ if (changed) {
4531
+ menu.classed('active', mode != 'off');
4532
+ _editMode = mode;
4533
+ onModeChange();
4534
+ updateArrowButton();
4535
+ updateSelectionHighlight();
4474
4536
  }
4475
- triggerHitEvent('click', e.data);
4476
- }, null, priority);
4537
+ }
4477
4538
 
4478
- // Hits are re-detected on 'hover' (if hit detection is active)
4479
- mouse.on('hover', function(e) {
4480
- if (storedData.pinned || !hitTest || !active) return;
4481
- if (e.hover && isOverMap(e)) {
4482
- // mouse is hovering directly over map area -- update hit detection
4483
- updateSelectionState(mergeHoverData(hitTest(e)));
4484
- } else if (targetIsRollover(e.originalEvent.target)) {
4485
- // don't update hit detection if mouse is over the rollover (to prevent
4486
- // on-off flickering)
4539
+ function onModeChange() {
4540
+ var mode = getInteractionMode();
4541
+ gui.state.interaction_mode = mode;
4542
+ gui.dispatchEvent('interaction_mode_change', {mode: mode});
4543
+ }
4544
+
4545
+ // Update button highlight and selected menu item highlight (if any)
4546
+ function updateArrowButton() {
4547
+ if (!menu) return;
4548
+ if (_menuOpen) {
4549
+ btn.addClass('open');
4487
4550
  } else {
4488
- updateSelectionState(mergeHoverData({ids:[]}));
4551
+ btn.removeClass('open');
4489
4552
  }
4490
- }, null, priority);
4553
+ btn.classed('hover', _menuOpen);
4554
+ // btn.classed('selected', active() && !_menuOpen);
4555
+ btn.classed('selected', active());
4556
+ }
4491
4557
 
4492
- function targetIsRollover(target) {
4493
- while (target.parentNode && target != target.parentNode) {
4494
- if (target.className && String(target.className).indexOf('rollover') > -1) {
4495
- return true;
4496
- }
4497
- target = target.parentNode;
4498
- }
4499
- return false;
4558
+ function updateSelectionHighlight() {
4559
+ El.findAll('.nav-menu-item').forEach(function(el) {
4560
+ el = El(el);
4561
+ el.classed('selected', el.attr('data-name') == _editMode);
4562
+ });
4500
4563
  }
4564
+ }
4501
4565
 
4502
- function noHitData() {return {ids: [], id: -1, pinned: false};}
4566
+ function Model(gui) {
4567
+ var self = new internal.Catalog();
4568
+ var deleteLayer = self.deleteLayer;
4569
+ utils.extend(self, EventDispatcher.prototype);
4503
4570
 
4504
- function mergeClickData(hitData) {
4505
- // mergeCurrentState(hitData);
4506
- // TOGGLE pinned state under some conditions
4507
- var id = hitData.ids.length > 0 ? hitData.ids[0] : -1;
4508
- hitData.id = id;
4509
- if (pinnable()) {
4510
- if (!storedData.pinned && id > -1) {
4511
- hitData.pinned = true; // add pin
4512
- } else if (storedData.pinned && storedData.id == id) {
4513
- delete hitData.pinned; // remove pin
4514
- // hitData.id = -1; // keep highlighting (pointer is still hovering)
4515
- } else if (storedData.pinned && id > -1) {
4516
- hitData.pinned = true; // stay pinned, switch id
4571
+ // override Catalog method (so -drop command will work in web console)
4572
+ self.deleteLayer = function(lyr, dataset) {
4573
+ var active, flags;
4574
+ deleteLayer.call(self, lyr, dataset);
4575
+ if (self.isEmpty()) {
4576
+ // refresh browser if deleted layer was the last layer
4577
+ window.location.href = window.location.href.toString();
4578
+ } else {
4579
+ // trigger event to update layer list and, if needed, the map view
4580
+ flags = {};
4581
+ active = self.getActiveLayer();
4582
+ if (active.layer != lyr) {
4583
+ flags.select = true;
4517
4584
  }
4518
- }
4519
- if (selectable()) {
4520
- if (id > -1) {
4521
- selectionIds = toggleId(id, selectionIds);
4585
+ internal.cleanupArcs(active.dataset);
4586
+ if (internal.layerHasPaths(lyr)) {
4587
+ flags.arc_count = true; // looks like a kludge, try to remove
4522
4588
  }
4523
- hitData.ids = selectionIds;
4589
+ self.updated(flags, active.layer, active.dataset);
4524
4590
  }
4525
- return hitData;
4526
- }
4591
+ };
4527
4592
 
4528
- function mergeHoverData(hitData) {
4529
- if (storedData.pinned) {
4530
- hitData.id = storedData.id;
4531
- hitData.pinned = true;
4532
- } else {
4533
- hitData.id = hitData.ids.length > 0 ? hitData.ids[0] : -1;
4593
+ self.updated = function(flags) {
4594
+ var targets = self.getDefaultTargets();
4595
+ var active = self.getActiveLayer();
4596
+ if (internal.countTargetLayers(targets) > 1) {
4597
+ self.setDefaultTarget([active.layer], active.dataset);
4598
+ gui.session.setTargetLayer(active.layer); // add -target command to target single layer
4534
4599
  }
4535
- if (selectable()) {
4536
- hitData.ids = selectionIds;
4537
- // kludge to inhibit hover effect while dragging a box
4538
- if (gui.keydown) hitData.id = -1;
4600
+ if (flags.select) {
4601
+ self.dispatchEvent('select', active);
4539
4602
  }
4540
- return hitData;
4541
- }
4603
+ self.dispatchEvent('update', utils.extend({flags: flags}, active));
4604
+ };
4542
4605
 
4543
- function pinnedId() {
4544
- return storedData.pinned ? storedData.id : -1;
4545
- }
4606
+ self.selectLayer = function(lyr, dataset) {
4607
+ if (self.getActiveLayer().layer == lyr) return;
4608
+ self.setDefaultTarget([lyr], dataset);
4609
+ self.updated({select: true});
4610
+ gui.session.setTargetLayer(lyr);
4611
+ };
4546
4612
 
4547
- function toggleId(id, ids) {
4548
- if (ids.indexOf(id) > -1) {
4549
- return utils.difference(ids, [id]);
4550
- }
4551
- return [id].concat(ids);
4552
- }
4613
+ self.selectNextLayer = function() {
4614
+ var next = self.findNextLayer(self.getActiveLayer().layer);
4615
+ if (next) self.selectLayer(next.layer, next.dataset);
4616
+ };
4553
4617
 
4554
- // If hit ids have changed, update stored hit ids and fire 'hover' event
4555
- // evt: (optional) mouse event
4556
- function updateSelectionState(newData) {
4557
- var nonEmpty = newData && (newData.ids.length || newData.id > -1);
4558
- transientIds = [];
4559
- if (!newData) {
4560
- newData = noHitData();
4561
- selectionIds = [];
4562
- }
4563
- if (!testHitChange(storedData, newData)) {
4564
- return;
4565
- }
4566
- storedData = newData;
4567
- gui.container.findChild('.map-layers').classed('symbol-hit', nonEmpty);
4568
- if (active) {
4569
- triggerHitEvent('change');
4570
- }
4618
+ self.selectPrevLayer = function() {
4619
+ var prev = self.findPrevLayer(self.getActiveLayer().layer);
4620
+ if (prev) self.selectLayer(prev.layer, prev.dataset);
4621
+ };
4622
+
4623
+ return self;
4624
+ }
4625
+
4626
+ function getShapeHitTest(displayLayer, ext) {
4627
+ var geoType = displayLayer.layer.geometry_type;
4628
+ var test;
4629
+ if (geoType == 'point' && displayLayer.style.type == 'styled') {
4630
+ test = getGraduatedCircleTest(getRadiusFunction(displayLayer.style));
4631
+ } else if (geoType == 'point') {
4632
+ test = pointTest;
4633
+ } else if (geoType == 'polyline') {
4634
+ test = polylineTest;
4635
+ } else if (geoType == 'polygon') {
4636
+ test = polygonTest;
4637
+ } else {
4638
+ error("Unexpected geometry type:", geoType);
4571
4639
  }
4640
+ return test;
4572
4641
 
4573
- // check if an event is used in the current interaction mode
4574
- function eventIsEnabled(type) {
4575
- if (type == 'click' && !clickable()) {
4576
- return false;
4577
- }
4578
- if ((type == 'drag' || type == 'dragstart' || type == 'dragend') && !draggable()) {
4579
- return false;
4580
- }
4581
- return true;
4642
+ // Convert pixel distance to distance in coordinate units.
4643
+ function getHitBuffer(pix) {
4644
+ return pix / ext.getTransform().mx;
4582
4645
  }
4583
4646
 
4584
- function isOverMap(e) {
4585
- return e.x >= 0 && e.y >= 0 && e.x < ext.width() && e.y < ext.height();
4647
+ // reduce hit threshold when zoomed out
4648
+ function getZoomAdjustedHitBuffer(pix, minPix) {
4649
+ var scale = ext.scale();
4650
+ if (scale < 1) {
4651
+ pix *= scale;
4652
+ }
4653
+ if (minPix > 0 && pix < minPix) pix = minPix;
4654
+ return getHitBuffer(pix);
4586
4655
  }
4587
4656
 
4588
- function handlePointerEvent(e) {
4589
- if (!hitTest || !active) return;
4590
- if (self.getHitId() == -1) return; // ignore pointer events when no features are being hit
4591
- // don't block pan and other navigation in modes when they are not being used
4592
- if (eventIsEnabled(e.type)) {
4593
- e.stopPropagation(); // block navigation
4594
- triggerHitEvent(e.type, e.data);
4657
+ function polygonTest(x, y) {
4658
+ var maxDist = getZoomAdjustedHitBuffer(5, 1),
4659
+ cands = findHitCandidates(x, y, maxDist),
4660
+ hits = [],
4661
+ cand, hitId;
4662
+ for (var i=0; i<cands.length; i++) {
4663
+ cand = cands[i];
4664
+ if (geom.testPointInPolygon(x, y, cand.shape, displayLayer.arcs)) {
4665
+ hits.push(cand.id);
4666
+ }
4667
+ }
4668
+ if (cands.length > 0 && hits.length === 0) {
4669
+ // secondary detection: proximity, if not inside a polygon
4670
+ sortByDistance(x, y, cands, displayLayer.arcs);
4671
+ hits = pickNearestCandidates(cands, 0, maxDist);
4595
4672
  }
4673
+ return hits;
4596
4674
  }
4597
4675
 
4598
- // d: event data (may be a pointer event object, an ordinary object or null)
4599
- function triggerHitEvent(type, d) {
4600
- // Merge stored hit data into the event data
4601
- var eventData = utils.extend({mode: interactionMode}, d || {}, storedData);
4602
- if (transientIds.length) {
4603
- eventData.ids = utils.uniq(transientIds.concat(eventData.ids || []));
4676
+ function pickNearestCandidates(sorted, bufDist, maxDist) {
4677
+ var hits = [],
4678
+ cand, minDist;
4679
+ for (var i=0; i<sorted.length; i++) {
4680
+ cand = sorted[i];
4681
+ if (cand.dist < maxDist !== true) {
4682
+ break;
4683
+ } else if (i === 0) {
4684
+ minDist = cand.dist;
4685
+ } else if (cand.dist - minDist > bufDist) {
4686
+ break;
4687
+ }
4688
+ hits.push(cand.id);
4604
4689
  }
4605
- self.dispatchEvent(type, eventData);
4690
+ return hits;
4606
4691
  }
4607
4692
 
4608
- // Test if two hit data objects are equivalent
4609
- function testHitChange(a, b) {
4610
- // check change in 'container', e.g. so moving from anchor hit to label hit
4611
- // is detected
4612
- if (sameIds(a.ids, b.ids) && a.container == b.container && a.pinned == b.pinned && a.id == b.id) {
4613
- return false;
4614
- }
4615
- return true;
4693
+ function polylineTest(x, y) {
4694
+ var maxDist = getZoomAdjustedHitBuffer(15, 2),
4695
+ bufDist = getZoomAdjustedHitBuffer(0.05), // tiny threshold for hitting almost-identical lines
4696
+ cands = findHitCandidates(x, y, maxDist);
4697
+ sortByDistance(x, y, cands, displayLayer.arcs);
4698
+ return pickNearestCandidates(cands, bufDist, maxDist);
4616
4699
  }
4617
4700
 
4618
- function sameIds(a, b) {
4619
- if (a.length != b.length) return false;
4620
- for (var i=0; i<a.length; i++) {
4621
- if (a[i] !== b[i]) return false;
4701
+ function sortByDistance(x, y, cands, arcs) {
4702
+ for (var i=0; i<cands.length; i++) {
4703
+ cands[i].dist = geom.getPointToShapeDistance(x, y, cands[i].shape, arcs);
4622
4704
  }
4623
- return true;
4705
+ utils.sortOn(cands, 'dist');
4624
4706
  }
4625
4707
 
4626
- return self;
4627
- }
4628
-
4629
- function CoordinatesDisplay(gui, ext, mouse) {
4630
- var readout = gui.container.findChild('.coordinate-info').hide();
4631
- var enabled = false;
4632
-
4633
- gui.model.on('select', function(e) {
4634
- enabled = !!e.layer.geometry_type; // no display on tabular layers
4635
- readout.hide();
4636
- });
4637
-
4638
- readout.on('copy', function(e) {
4639
- // remove selection on copy (using timeout or else copy is cancelled)
4640
- setTimeout(function() {
4641
- window.getSelection().removeAllRanges();
4642
- }, 50);
4643
- });
4644
-
4645
- // clear coords when map pans
4646
- ext.on('change', function() {
4647
- clearCoords();
4648
- // shapes may change along with map scale
4649
- // target = lyr ? lyr.getDisplayLayer() : null;
4650
- });
4651
-
4652
- mouse.on('leave', clearCoords);
4653
-
4654
- mouse.on('click', function(e) {
4655
- if (!enabled) return;
4656
- GUI.selectElement(readout.node());
4657
- });
4658
-
4659
- mouse.on('hover', onMouseChange);
4660
- mouse.on('drag', onMouseChange, null, 10); // high priority so editor doesn't block propagation
4708
+ function pointTest(x, y) {
4709
+ var bullseyeDist = 2, // hit all points w/in 2 px
4710
+ tinyDist = 0.5,
4711
+ toPx = ext.getTransform().mx,
4712
+ hits = [],
4713
+ hitThreshold = 25,
4714
+ newThreshold = Infinity;
4661
4715
 
4662
- function onMouseChange(e) {
4663
- if (!enabled) return;
4664
- if (isOverMap(e)) {
4665
- displayCoords(ext.translatePixelCoords(e.x, e.y));
4666
- } else {
4667
- clearCoords();
4668
- }
4716
+ internal.forEachPoint(displayLayer.layer.shapes, function(p, id) {
4717
+ var dist = geom.distance2D(x, y, p[0], p[1]) * toPx;
4718
+ if (dist > hitThreshold) return;
4719
+ // got a hit
4720
+ if (dist < newThreshold) {
4721
+ // start a collection of hits
4722
+ hits = [id];
4723
+ hitThreshold = Math.max(bullseyeDist, dist + tinyDist);
4724
+ newThreshold = dist < bullseyeDist ? -1 : dist - tinyDist;
4725
+ } else {
4726
+ // add to hits if inside bullseye or is same dist as previous hit
4727
+ hits.push(id);
4728
+ }
4729
+ });
4730
+ // console.log(hitThreshold, bullseye);
4731
+ return utils.uniq(hits); // multipoint features can register multiple hits
4669
4732
  }
4670
4733
 
4671
- function displayCoords(p) {
4672
- var decimals = internal.getBoundsPrecisionForDisplay(ext.getBounds().toArray());
4673
- var str = internal.getRoundedCoordString(p, decimals);
4674
- readout.text(str).show();
4734
+ function getRadiusFunction(style) {
4735
+ var o = {};
4736
+ if (style.styler) {
4737
+ return function(i) {
4738
+ style.styler(o, i);
4739
+ return o.radius || 0;
4740
+ };
4741
+ }
4742
+ return function() {return style.radius || 0;};
4675
4743
  }
4676
4744
 
4677
- function clearCoords() {
4678
- readout.hide();
4745
+ function getGraduatedCircleTest(radius) {
4746
+ return function(x, y) {
4747
+ var hits = [],
4748
+ margin = getHitBuffer(12),
4749
+ limit = getHitBuffer(50), // short-circuit hit test beyond this threshold
4750
+ directHit = false,
4751
+ hitRadius = 0,
4752
+ hitDist;
4753
+ internal.forEachPoint(displayLayer.layer.shapes, function(p, id) {
4754
+ var distSq = geom.distanceSq(x, y, p[0], p[1]);
4755
+ var isHit = false;
4756
+ var isOver, isNear, r, d, rpix;
4757
+ if (distSq > limit * limit) return;
4758
+ rpix = radius(id);
4759
+ r = getHitBuffer(rpix + 1); // increase effective radius to make small bubbles easier to hit in clusters
4760
+ d = Math.sqrt(distSq) - r; // pointer distance from edge of circle (negative = inside)
4761
+ isOver = d < 0;
4762
+ isNear = d < margin;
4763
+ if (!isNear || rpix > 0 === false) {
4764
+ isHit = false;
4765
+ } else if (hits.length === 0) {
4766
+ isHit = isNear;
4767
+ } else if (!directHit && isOver) {
4768
+ isHit = true;
4769
+ } else if (directHit && isOver) {
4770
+ isHit = r == hitRadius ? d <= hitDist : r < hitRadius; // smallest bubble wins if multiple direct hits
4771
+ } else if (!directHit && !isOver) {
4772
+ // closest to bubble edge wins
4773
+ isHit = hitDist == d ? r <= hitRadius : d < hitDist; // closest bubble wins if multiple indirect hits
4774
+ }
4775
+ if (isHit) {
4776
+ if (hits.length > 0 && (r != hitRadius || d != hitDist)) {
4777
+ hits = [];
4778
+ }
4779
+ hitRadius = r;
4780
+ hitDist = d;
4781
+ directHit = isOver;
4782
+ hits.push(id);
4783
+ }
4784
+ });
4785
+ return hits;
4786
+ };
4679
4787
  }
4680
4788
 
4681
- function isOverMap(e) {
4682
- return e.x >= 0 && e.y >= 0 && e.x < ext.width() && e.y < ext.height();
4789
+ function findHitCandidates(x, y, dist) {
4790
+ var arcs = displayLayer.arcs,
4791
+ index = {},
4792
+ cands = [],
4793
+ bbox = [];
4794
+ displayLayer.layer.shapes.forEach(function(shp, shpId) {
4795
+ var cand;
4796
+ for (var i = 0, n = shp && shp.length; i < n; i++) {
4797
+ arcs.getSimpleShapeBounds2(shp[i], bbox);
4798
+ if (x + dist < bbox[0] || x - dist > bbox[2] ||
4799
+ y + dist < bbox[1] || y - dist > bbox[3]) {
4800
+ continue; // bbox non-intersection
4801
+ }
4802
+ cand = index[shpId];
4803
+ if (!cand) {
4804
+ cand = index[shpId] = {shape: [], id: shpId, dist: 0};
4805
+ cands.push(cand);
4806
+ }
4807
+ cand.shape.push(shp[i]);
4808
+ }
4809
+ });
4810
+ return cands;
4683
4811
  }
4684
4812
  }
4685
4813
 
4686
- function getTimerFunction() {
4687
- return typeof requestAnimationFrame == 'function' ?
4688
- requestAnimationFrame : function(cb) {setTimeout(cb, 25);};
4689
- }
4690
-
4691
- function Timer() {
4692
- var self = this,
4693
- running = false,
4694
- busy = false,
4695
- tickTime, startTime, duration;
4696
-
4697
- this.start = function(ms) {
4698
- var now = +new Date();
4699
- duration = ms || Infinity;
4700
- startTime = now;
4701
- running = true;
4702
- if (!busy) startTick(now);
4703
- };
4814
+ function getSvgHitTest(displayLayer) {
4704
4815
 
4705
- this.stop = function() {
4706
- running = false;
4816
+ return function(pointerEvent) {
4817
+ // target could be a part of an SVG symbol, or the SVG element, or something else
4818
+ var target = pointerEvent.originalEvent.target;
4819
+ var symbolNode = getSymbolNode(target);
4820
+ if (!symbolNode) {
4821
+ return null;
4822
+ }
4823
+ return {
4824
+ targetId: getSymbolNodeId(symbolNode), // TODO: some validation on id
4825
+ targetSymbol: symbolNode,
4826
+ targetNode: target,
4827
+ container: symbolNode.parentNode
4828
+ };
4707
4829
  };
4708
4830
 
4709
- function startTick(now) {
4710
- busy = true;
4711
- tickTime = now;
4712
- getTimerFunction()(onTick);
4713
- }
4714
-
4715
- function onTick() {
4716
- var now = +new Date(),
4717
- elapsed = now - startTime,
4718
- pct = Math.min((elapsed + 10) / duration, 1),
4719
- done = pct >= 1;
4720
- if (!running) { // interrupted
4721
- busy = false;
4722
- return;
4831
+ // target: event target (could be any DOM element)
4832
+ function getSymbolNode(target) {
4833
+ var node = target;
4834
+ while (node && nodeHasSymbolTagType(node)) {
4835
+ if (isSymbolNode(node)) {
4836
+ return node;
4837
+ }
4838
+ node = node.parentElement;
4723
4839
  }
4724
- if (done) running = false;
4725
- self.dispatchEvent('tick', {
4726
- elapsed: elapsed,
4727
- pct: pct,
4728
- done: done,
4729
- time: now,
4730
- tickTime: now - tickTime
4731
- });
4732
- busy = false;
4733
- if (running) startTick(now);
4840
+ return null;
4734
4841
  }
4735
- }
4736
-
4737
- utils.inherit(Timer, EventDispatcher);
4738
4842
 
4739
- function Tween(ease) {
4740
- var self = this,
4741
- timer = new Timer(),
4742
- start, end;
4843
+ // TODO: switch to attribute detection
4844
+ function nodeHasSymbolTagType(node) {
4845
+ var tag = node.tagName;
4846
+ return tag == 'g' || tag == 'tspan' || tag == 'text' || tag == 'image' ||
4847
+ tag == 'path' || tag == 'circle' || tag == 'rect' || tag == 'line';
4848
+ }
4743
4849
 
4744
- timer.on('tick', onTick);
4850
+ function isSymbolNode(node) {
4851
+ return node.hasAttribute('data-id') && (node.tagName == 'text' || node.tagName == 'g');
4852
+ }
4745
4853
 
4746
- this.start = function(a, b, duration) {
4747
- start = a;
4748
- end = b;
4749
- timer.start(duration || 500);
4750
- };
4854
+ function isSymbolChildNode(node) {
4751
4855
 
4752
- function onTick(e) {
4753
- var pct = ease ? ease(e.pct) : e.pct,
4754
- val = end * pct + start * (1 - pct);
4755
- self.dispatchEvent('change', {value: val});
4756
4856
  }
4757
- }
4758
4857
 
4759
- utils.inherit(Tween, EventDispatcher);
4858
+ function getChildId(childNode) {
4760
4859
 
4761
- Tween.sineInOut = function(n) {
4762
- return 0.5 - Math.cos(n * Math.PI) / 2;
4763
- };
4860
+ }
4764
4861
 
4765
- Tween.quadraticOut = function(n) {
4766
- return 1 - Math.pow((1 - n), 2);
4767
- };
4862
+ function getSymbolId(symbolNode) {
4768
4863
 
4769
- function ElementPosition(ref) {
4770
- var self = this,
4771
- el = El(ref),
4772
- pageX = 0,
4773
- pageY = 0,
4774
- width = 0,
4775
- height = 0;
4864
+ }
4776
4865
 
4777
- el.on('mouseover', update);
4778
- if (window.onorientationchange) window.addEventListener('orientationchange', update);
4779
- window.addEventListener('scroll', update);
4780
- window.addEventListener('resize', update);
4866
+ function getFeatureId(symbolNode) {
4781
4867
 
4782
- // trigger an update, e.g. when map container is resized
4783
- this.update = function() {
4784
- update();
4785
- };
4868
+ }
4786
4869
 
4787
- this.resize = function(w, h) {
4788
- el.css('width', w).css('height', h);
4789
- update();
4790
- };
4870
+ }
4791
4871
 
4792
- this.width = function() { return width; };
4793
- this.height = function() { return height; };
4794
- this.position = function() {
4795
- return {
4796
- element: el.node(),
4797
- pageX: pageX,
4798
- pageY: pageY,
4799
- width: width,
4800
- height: height
4801
- };
4802
- };
4872
+ function getPointerHitTest(mapLayer, ext) {
4873
+ var shapeTest, svgTest, targetLayer;
4874
+ if (!mapLayer || !internal.layerHasGeometry(mapLayer.layer)) {
4875
+ return null;
4876
+ }
4877
+ shapeTest = getShapeHitTest(mapLayer, ext);
4878
+ svgTest = getSvgHitTest(mapLayer);
4803
4879
 
4804
- function update() {
4805
- var div = el.node(),
4806
- xy = getPageXY(div),
4807
- w = div.clientWidth,
4808
- h = div.clientHeight,
4809
- x = xy.x,
4810
- y = xy.y,
4811
- resized = w != width || h != height,
4812
- moved = x != pageX || y != pageY;
4813
- if (resized || moved) {
4814
- pageX = x;
4815
- pageY = y;
4816
- width = w;
4817
- height = h;
4818
- self.dispatchEvent('change', self.position());
4819
- if (resized) {
4820
- self.dispatchEvent('resize', self.position());
4821
- }
4880
+ // e: pointer event
4881
+ return function(e) {
4882
+ var p = ext.translatePixelCoords(e.x, e.y);
4883
+ var data = {
4884
+ ids: shapeTest(p[0], p[1]) || []
4885
+ };
4886
+ var svgData = svgTest(e); // null or a data object
4887
+ if (svgData) { // mouse is over an SVG symbol
4888
+ utils.extend(data, svgData);
4889
+ // placing symbol id in front of any other hits
4890
+ data.ids = utils.uniq([svgData.targetId].concat(data.ids));
4822
4891
  }
4823
- }
4824
- update();
4892
+ data.id = data.ids.length > 0 ? data.ids[0] : -1;
4893
+ return data;
4894
+ };
4825
4895
  }
4826
4896
 
4827
- utils.inherit(ElementPosition, EventDispatcher);
4897
+ function InteractiveSelection(gui, ext, mouse) {
4898
+ var self = new EventDispatcher();
4899
+ var storedData = noHitData(); // may include additional data from SVG symbol hit (e.g. hit node)
4900
+ var selectionIds = [];
4901
+ var transientIds = []; // e.g. hit ids while dragging a box
4902
+ var active = false;
4903
+ var interactionMode;
4904
+ var targetLayer;
4905
+ var hitTest;
4906
+ // event priority is higher than navigation, so stopping propagation disables
4907
+ // pan navigation
4908
+ var priority = 2;
4828
4909
 
4829
- function MouseWheelDirection() {
4830
- var prevTime = 0;
4831
- var getAvgDir;
4910
+ // init keyboard controls for pinned features
4911
+ gui.keyboard.on('keydown', function(evt) {
4912
+ var e = evt.originalEvent;
4832
4913
 
4833
- // returns 1, -1 or 0 to indicate direction of scroll
4834
- // use avg of three values, as a buffer against single anomalous values
4835
- return function(e, now) {
4836
- var delta = e.wheelDelta || -e.detail || 0;
4837
- var dir = delta > 0 && 1 || delta < 0 && -1 || 0;
4838
- var avg;
4839
- if (!getAvgDir || now - prevTime > 300) {
4840
- getAvgDir = LimitedAverage(3); // reset if wheel has paused
4841
- }
4842
- prevTime = now;
4843
- avg = getAvgDir(dir) || dir; // handle average == 0
4844
- return avg > 0 && 1 || avg < 0 && -1 || 0;
4845
- };
4846
- }
4914
+ if (gui.interaction.getMode() == 'off' || !targetLayer) return;
4847
4915
 
4848
- function LimitedAverage(maxSize) {
4849
- var arr = [];
4850
- return function(val) {
4851
- var sum = 0,
4852
- i = -1;
4853
- arr.push(val);
4854
- if (arr.length > maxSize) arr.shift();
4855
- while (++i < arr.length) {
4856
- sum += arr[i];
4916
+ // esc key clears selection (unless in an editing mode -- esc key also exits current mode)
4917
+ if (e.keyCode == 27 && !gui.getMode()) {
4918
+ self.clearSelection();
4919
+ return;
4857
4920
  }
4858
- return sum / arr.length;
4859
- };
4860
- }
4861
-
4862
- // @mouse: MouseArea object
4863
- function MouseWheel(mouse) {
4864
- var self = this,
4865
- active = false,
4866
- timer = new Timer().addEventListener('tick', onTick),
4867
- sustainInterval = 150,
4868
- fadeDelay = 70,
4869
- eventTime = 0,
4870
- getAverageRate = LimitedAverage(10),
4871
- getWheelDirection = MouseWheelDirection(),
4872
- wheelDirection;
4873
4921
 
4874
- if (window.onmousewheel !== undefined) { // ie, webkit
4875
- window.addEventListener('mousewheel', handleWheel, {passive: false});
4876
- } else { // firefox
4877
- window.addEventListener('DOMMouseScroll', handleWheel);
4878
- }
4922
+ // ignore keypress if no feature is selected or user is editing text
4923
+ if (pinnedId() == -1 || GUI.getInputElement()) return;
4879
4924
 
4880
- function updateSustainInterval(eventRate) {
4881
- var fadeInterval = 80;
4882
- fadeDelay = eventRate + 50; // adding a little extra time helps keep trackpad scrolling smooth in Firefox
4883
- sustainInterval = fadeDelay + fadeInterval;
4884
- }
4925
+ if (e.keyCode == 37 || e.keyCode == 39) {
4926
+ // L/R arrow keys
4927
+ // advance pinned feature
4928
+ advanceSelectedFeature(e.keyCode == 37 ? -1 : 1);
4929
+ e.stopPropagation();
4885
4930
 
4886
- function handleWheel(evt) {
4887
- var now = +new Date();
4888
- wheelDirection = getWheelDirection(evt, now);
4889
- if (evt.ctrlKey) {
4890
- // Prevent pinch-zoom in Chrome (doesn't work in Safari, though)
4891
- evt.preventDefault();
4892
- evt.stopImmediatePropagation();
4893
- }
4894
- if (!mouse.isOver()) return;
4895
- if (wheelDirection === 0) {
4896
- // first event may not have a direction, e.g. if 'smooth scrolling' is on
4897
- return;
4931
+ } else if (e.keyCode == 8) {
4932
+ // DELETE key
4933
+ // delete pinned feature
4934
+ // to help protect against inadvertent deletion, don't delete
4935
+ // when console is open or a popup menu is open
4936
+ if (!gui.getMode() && !gui.consoleIsOpen()) {
4937
+ internal.deleteFeatureById(targetLayer.layer, pinnedId());
4938
+ self.clearSelection();
4939
+ gui.model.updated({flags: 'filter'}); // signal map to update
4940
+ }
4898
4941
  }
4899
- evt.preventDefault();
4900
- if (!active) {
4901
- active = true;
4902
- self.dispatchEvent('mousewheelstart');
4903
- } else {
4904
- updateSustainInterval(getAverageRate(now - eventTime));
4942
+ }, !!'capture'); // preempt the layer control's arrow key handler
4943
+
4944
+ self.setLayer = function(mapLayer) {
4945
+ hitTest = getPointerHitTest(mapLayer, ext);
4946
+ if (!hitTest) {
4947
+ hitTest = function() {return {ids: []};};
4905
4948
  }
4906
- eventTime = now;
4907
- timer.start(sustainInterval);
4949
+ targetLayer = mapLayer;
4950
+ };
4951
+
4952
+ function turnOn(mode) {
4953
+ interactionMode = mode;
4954
+ active = true;
4908
4955
  }
4909
4956
 
4910
- function onTick(evt) {
4911
- var tickInterval = evt.time - eventTime,
4912
- multiplier = evt.tickTime / 25,
4913
- fadeFactor = 0,
4914
- obj;
4915
- if (tickInterval > fadeDelay) {
4916
- fadeFactor = Math.min(1, (tickInterval - fadeDelay) / (sustainInterval - fadeDelay));
4917
- }
4918
- if (evt.done) {
4957
+ function turnOff() {
4958
+ if (active) {
4959
+ updateSelectionState(null); // no hit data, no event
4919
4960
  active = false;
4920
- } else {
4921
- if (fadeFactor > 0) {
4922
- // Decelerate towards the end of the sustain interval (for smoother zooming)
4923
- multiplier *= Tween.quadraticOut(1 - fadeFactor);
4924
- }
4925
- obj = utils.extend({direction: wheelDirection, multiplier: multiplier}, mouse.mouseData());
4926
- self.dispatchEvent('mousewheel', obj);
4927
4961
  }
4928
4962
  }
4929
- }
4930
4963
 
4931
- utils.inherit(MouseWheel, EventDispatcher);
4964
+ function selectable() {
4965
+ return interactionMode == 'selection';
4966
+ }
4932
4967
 
4968
+ function pinnable() {
4969
+ return clickable() && interactionMode != 'selection';
4970
+ }
4933
4971
 
4934
- function MouseArea(element, pos) {
4935
- var _pos = pos || new ElementPosition(element),
4936
- _areaPos = _pos.position(),
4937
- _self = this,
4938
- _dragging = false,
4939
- _isOver = false,
4940
- _disabled = false,
4941
- _prevEvt,
4942
- _downEvt;
4972
+ function draggable() {
4973
+ return interactionMode == 'vertices' || interactionMode == 'location' || interactionMode == 'labels';
4974
+ }
4943
4975
 
4944
- _pos.on('change', function() {_areaPos = _pos.position();});
4945
- // TODO: think about touch events
4946
- document.addEventListener('mousemove', onMouseMove);
4947
- document.addEventListener('mousedown', onMouseDown);
4948
- document.addEventListener('mouseup', onMouseUp);
4949
- element.addEventListener('mouseover', onAreaEnter);
4950
- element.addEventListener('mousemove', onAreaEnter);
4951
- element.addEventListener('mouseout', onAreaOut);
4952
- element.addEventListener('mousedown', onAreaDown);
4953
- element.addEventListener('dblclick', onAreaDblClick);
4976
+ function clickable() {
4977
+ // click used to pin popup and select features
4978
+ return interactionMode == 'data' || interactionMode == 'info' || interactionMode == 'selection';
4979
+ }
4954
4980
 
4955
- this.enable = function() {
4956
- if (!_disabled) return;
4957
- _disabled = false;
4958
- element.style.pointerEvents = 'auto';
4981
+ self.getHitId = function() {return storedData.id;};
4982
+
4983
+ // Get a reference to the active layer, so listeners to hit events can interact
4984
+ // with data and shapes
4985
+ self.getHitTarget = function() {
4986
+ return targetLayer;
4959
4987
  };
4960
4988
 
4961
- this.stopDragging = function() {
4962
- if (_downEvt) {
4963
- if (_dragging) stopDragging(_downEvt);
4964
- _downEvt = null;
4989
+ self.addSelectionIds = function(ids) {
4990
+ turnOn('selection');
4991
+ selectionIds = utils.uniq(selectionIds.concat(ids));
4992
+ ids = utils.uniq(storedData.ids.concat(ids));
4993
+ updateSelectionState({ids: ids});
4994
+ };
4995
+
4996
+ self.setTransientIds = function(ids) {
4997
+ // turnOn('selection');
4998
+ transientIds = ids || [];
4999
+ if (active) {
5000
+ triggerHitEvent('change');
4965
5001
  }
4966
5002
  };
4967
5003
 
4968
- this.disable = function() {
4969
- if (_disabled) return;
4970
- _disabled = true;
4971
- if (_isOver) onAreaOut();
4972
- this.stopDragging();
4973
- element.style.pointerEvents = 'none';
5004
+ self.clearSelection = function() {
5005
+ updateSelectionState(null);
4974
5006
  };
4975
5007
 
4976
- this.isOver = function() {
4977
- return _isOver;
5008
+ self.clearHover = function() {
5009
+ updateSelectionState(mergeHoverData({ids: []}));
4978
5010
  };
4979
5011
 
4980
- this.isDown = function() {
4981
- return !!_downEvt;
5012
+ self.getSelectionIds = function() {
5013
+ return selectionIds.concat();
4982
5014
  };
4983
5015
 
4984
- this.mouseData = function() {
4985
- return utils.extend({}, _prevEvt);
5016
+ self.getTargetDataTable = function() {
5017
+ var targ = self.getHitTarget();
5018
+ return targ && targ.layer.data || null;
4986
5019
  };
4987
5020
 
4988
- function onAreaDown(e) {
4989
- e.preventDefault(); // prevent text selection cursor on drag
5021
+ // get function for selecting next or prev feature within the current set of
5022
+ // selected features
5023
+ self.getSwitchTrigger = function(diff) {
5024
+ return function() {
5025
+ switchWithinSelection(diff);
5026
+ };
5027
+ };
5028
+
5029
+ // diff: 1 or -1
5030
+ function advanceSelectedFeature(diff) {
5031
+ var n = internal.getFeatureCount(targetLayer.layer);
5032
+ if (n < 2 || pinnedId() == -1) return;
5033
+ storedData.id = (pinnedId() + n + diff) % n;
5034
+ storedData.ids = [storedData.id];
5035
+ triggerHitEvent('change');
4990
5036
  }
4991
5037
 
4992
- function onAreaEnter() {
4993
- if (!_isOver) {
4994
- _isOver = true;
4995
- _self.dispatchEvent('enter');
4996
- }
5038
+ // diff: 1 or -1
5039
+ function switchWithinSelection(diff) {
5040
+ var id = pinnedId();
5041
+ var i = storedData.ids.indexOf(id);
5042
+ var n = storedData.ids.length;
5043
+ if (i < 0 || n < 2) return;
5044
+ storedData.id = storedData.ids[(i + diff + n) % n];
5045
+ triggerHitEvent('change');
4997
5046
  }
4998
5047
 
4999
- function onAreaOut() {
5000
- _isOver = false;
5001
- _self.dispatchEvent('leave');
5048
+ // make sure popup is unpinned and turned off when switching editing modes
5049
+ // (some modes do not support pinning)
5050
+ gui.on('interaction_mode_change', function(e) {
5051
+ updateSelectionState(null);
5052
+ if (e.mode == 'off' || e.mode == 'box') {
5053
+ turnOff();
5054
+ } else {
5055
+ turnOn(e.mode);
5056
+ }
5057
+ });
5058
+
5059
+ gui.on('box_drag_start', function() {
5060
+ self.clearHover();
5061
+ });
5062
+
5063
+ mouse.on('dblclick', handlePointerEvent, null, priority);
5064
+ mouse.on('dragstart', handlePointerEvent, null, priority);
5065
+ mouse.on('drag', handlePointerEvent, null, priority);
5066
+ mouse.on('dragend', handlePointerEvent, null, priority);
5067
+
5068
+ mouse.on('click', function(e) {
5069
+ if (!hitTest || !active) return;
5070
+ e.stopPropagation();
5071
+
5072
+ // TODO: move pinning to inspection control?
5073
+ if (clickable()) {
5074
+ updateSelectionState(mergeClickData(hitTest(e)));
5075
+ }
5076
+ triggerHitEvent('click', e.data);
5077
+ }, null, priority);
5078
+
5079
+ // Hits are re-detected on 'hover' (if hit detection is active)
5080
+ mouse.on('hover', function(e) {
5081
+ if (storedData.pinned || !hitTest || !active) return;
5082
+ if (e.hover && isOverMap(e)) {
5083
+ // mouse is hovering directly over map area -- update hit detection
5084
+ updateSelectionState(mergeHoverData(hitTest(e)));
5085
+ } else if (targetIsRollover(e.originalEvent.target)) {
5086
+ // don't update hit detection if mouse is over the rollover (to prevent
5087
+ // on-off flickering)
5088
+ } else {
5089
+ updateSelectionState(mergeHoverData({ids:[]}));
5090
+ }
5091
+ }, null, priority);
5092
+
5093
+ function targetIsRollover(target) {
5094
+ while (target.parentNode && target != target.parentNode) {
5095
+ if (target.className && String(target.className).indexOf('rollover') > -1) {
5096
+ return true;
5097
+ }
5098
+ target = target.parentNode;
5099
+ }
5100
+ return false;
5002
5101
  }
5003
5102
 
5004
- function onMouseUp(e) {
5005
- var evt = procMouseEvent(e),
5006
- elapsed, dx, dy;
5007
- if (_dragging) {
5008
- stopDragging(evt);
5103
+ function noHitData() {return {ids: [], id: -1, pinned: false};}
5104
+
5105
+ function mergeClickData(hitData) {
5106
+ // mergeCurrentState(hitData);
5107
+ // TOGGLE pinned state under some conditions
5108
+ var id = hitData.ids.length > 0 ? hitData.ids[0] : -1;
5109
+ hitData.id = id;
5110
+ if (pinnable()) {
5111
+ if (!storedData.pinned && id > -1) {
5112
+ hitData.pinned = true; // add pin
5113
+ } else if (storedData.pinned && storedData.id == id) {
5114
+ delete hitData.pinned; // remove pin
5115
+ // hitData.id = -1; // keep highlighting (pointer is still hovering)
5116
+ } else if (storedData.pinned && id > -1) {
5117
+ hitData.pinned = true; // stay pinned, switch id
5118
+ }
5009
5119
  }
5010
- if (_downEvt) {
5011
- elapsed = evt.time - _downEvt.time;
5012
- dx = evt.pageX - _downEvt.pageX;
5013
- dy = evt.pageY - _downEvt.pageY;
5014
- if (_isOver && elapsed < 500 && Math.sqrt(dx * dx + dy * dy) < 6) {
5015
- _self.dispatchEvent('click', evt);
5120
+ if (selectable()) {
5121
+ if (id > -1) {
5122
+ selectionIds = toggleId(id, selectionIds);
5016
5123
  }
5017
- _downEvt = null;
5124
+ hitData.ids = selectionIds;
5125
+ }
5126
+ return hitData;
5127
+ }
5128
+
5129
+ function mergeHoverData(hitData) {
5130
+ if (storedData.pinned) {
5131
+ hitData.id = storedData.id;
5132
+ hitData.pinned = true;
5133
+ } else {
5134
+ hitData.id = hitData.ids.length > 0 ? hitData.ids[0] : -1;
5135
+ }
5136
+ if (selectable()) {
5137
+ hitData.ids = selectionIds;
5138
+ // kludge to inhibit hover effect while dragging a box
5139
+ if (gui.keydown) hitData.id = -1;
5140
+ }
5141
+ return hitData;
5142
+ }
5143
+
5144
+ function pinnedId() {
5145
+ return storedData.pinned ? storedData.id : -1;
5146
+ }
5147
+
5148
+ function toggleId(id, ids) {
5149
+ if (ids.indexOf(id) > -1) {
5150
+ return utils.difference(ids, [id]);
5151
+ }
5152
+ return [id].concat(ids);
5153
+ }
5154
+
5155
+ // If hit ids have changed, update stored hit ids and fire 'hover' event
5156
+ // evt: (optional) mouse event
5157
+ function updateSelectionState(newData) {
5158
+ var nonEmpty = newData && (newData.ids.length || newData.id > -1);
5159
+ transientIds = [];
5160
+ if (!newData) {
5161
+ newData = noHitData();
5162
+ selectionIds = [];
5163
+ }
5164
+ if (!testHitChange(storedData, newData)) {
5165
+ return;
5166
+ }
5167
+ storedData = newData;
5168
+ gui.container.findChild('.map-layers').classed('symbol-hit', nonEmpty);
5169
+ if (active) {
5170
+ triggerHitEvent('change');
5171
+ }
5172
+ }
5173
+
5174
+ // check if an event is used in the current interaction mode
5175
+ function eventIsEnabled(type) {
5176
+ if (type == 'click' && !clickable()) {
5177
+ return false;
5178
+ }
5179
+ if ((type == 'drag' || type == 'dragstart' || type == 'dragend') && !draggable()) {
5180
+ return false;
5018
5181
  }
5182
+ return true;
5019
5183
  }
5020
5184
 
5021
- function stopDragging(evt) {
5022
- _dragging = false;
5023
- _self.dispatchEvent('dragend', evt);
5185
+ function isOverMap(e) {
5186
+ return e.x >= 0 && e.y >= 0 && e.x < ext.width() && e.y < ext.height();
5024
5187
  }
5025
5188
 
5026
- function onMouseDown(e) {
5027
- if (e.button != 2 && e.which != 3) { // ignore right-click
5028
- _downEvt = procMouseEvent(e);
5189
+ function handlePointerEvent(e) {
5190
+ if (!hitTest || !active) return;
5191
+ if (self.getHitId() == -1) return; // ignore pointer events when no features are being hit
5192
+ // don't block pan and other navigation in modes when they are not being used
5193
+ if (eventIsEnabled(e.type)) {
5194
+ e.stopPropagation(); // block navigation
5195
+ triggerHitEvent(e.type, e.data);
5029
5196
  }
5030
5197
  }
5031
5198
 
5032
- function onMouseMove(e) {
5033
- var evt = procMouseEvent(e);
5034
- if (!_dragging && _downEvt && _downEvt.hover) {
5035
- _dragging = true;
5036
- _self.dispatchEvent('dragstart', evt);
5037
- }
5038
- if (evt.dx === 0 && evt.dy === 0) return; // seen in Chrome
5039
- if (_dragging) {
5040
- var obj = {
5041
- dragX: evt.pageX - _downEvt.pageX,
5042
- dragY: evt.pageY - _downEvt.pageY
5043
- };
5044
- _self.dispatchEvent('drag', utils.extend(obj, evt));
5045
- } else {
5046
- _self.dispatchEvent('hover', evt);
5199
+ // d: event data (may be a pointer event object, an ordinary object or null)
5200
+ function triggerHitEvent(type, d) {
5201
+ // Merge stored hit data into the event data
5202
+ var eventData = utils.extend({mode: interactionMode}, d || {}, storedData);
5203
+ if (transientIds.length) {
5204
+ eventData.ids = utils.uniq(transientIds.concat(eventData.ids || []));
5047
5205
  }
5206
+ self.dispatchEvent(type, eventData);
5048
5207
  }
5049
5208
 
5050
- function onAreaDblClick(e) {
5051
- if (_isOver) _self.dispatchEvent('dblclick', procMouseEvent(e));
5209
+ // Test if two hit data objects are equivalent
5210
+ function testHitChange(a, b) {
5211
+ // check change in 'container', e.g. so moving from anchor hit to label hit
5212
+ // is detected
5213
+ if (sameIds(a.ids, b.ids) && a.container == b.container && a.pinned == b.pinned && a.id == b.id) {
5214
+ return false;
5215
+ }
5216
+ return true;
5052
5217
  }
5053
5218
 
5054
- function procMouseEvent(e) {
5055
- var pageX = e.pageX,
5056
- pageY = e.pageY,
5057
- prev = _prevEvt;
5058
- _prevEvt = {
5059
- originalEvent: e,
5060
- shiftKey: e.shiftKey,
5061
- metaKey: e.metaKey,
5062
- ctrlKey: e.ctrlKey,
5063
- time: +new Date(),
5064
- pageX: pageX,
5065
- pageY: pageY,
5066
- hover: _isOver,
5067
- x: pageX - _areaPos.pageX,
5068
- y: pageY - _areaPos.pageY,
5069
- dx: prev ? pageX - prev.pageX : 0,
5070
- dy: prev ? pageY - prev.pageY : 0
5071
- };
5072
- return _prevEvt;
5219
+ function sameIds(a, b) {
5220
+ if (a.length != b.length) return false;
5221
+ for (var i=0; i<a.length; i++) {
5222
+ if (a[i] !== b[i]) return false;
5223
+ }
5224
+ return true;
5073
5225
  }
5226
+
5227
+ return self;
5074
5228
  }
5075
5229
 
5076
- utils.inherit(MouseArea, EventDispatcher);
5230
+ function CoordinatesDisplay(gui, ext, mouse) {
5231
+ var readout = gui.container.findChild('.coordinate-info').hide();
5232
+ var enabled = false;
5077
5233
 
5078
- function initVariableClick(node, cb) {
5079
- var downEvent = null;
5080
- var downTime = 0;
5234
+ gui.model.on('select', function(e) {
5235
+ enabled = !!e.layer.geometry_type; // no display on tabular layers
5236
+ readout.hide();
5237
+ });
5081
5238
 
5082
- node.addEventListener('mousedown', function(e) {
5083
- downEvent = e;
5084
- downTime = Date.now();
5239
+ readout.on('copy', function(e) {
5240
+ // remove selection on copy (using timeout or else copy is cancelled)
5241
+ setTimeout(function() {
5242
+ window.getSelection().removeAllRanges();
5243
+ }, 50);
5085
5244
  });
5086
5245
 
5087
- node.addEventListener('mouseup', function(upEvent) {
5088
- if (!downEvent) return;
5089
- var shift = Math.abs(downEvent.pageX - upEvent.pageX) +
5090
- Math.abs(downEvent.pageY - upEvent.pageY);
5091
- var elapsed = Date.now() - downTime;
5092
- if (shift > 5 || elapsed > 1000) return;
5093
- downEvent = null;
5094
- cb({time: elapsed});
5246
+ // clear coords when map pans
5247
+ ext.on('change', function() {
5248
+ clearCoords();
5249
+ // shapes may change along with map scale
5250
+ // target = lyr ? lyr.getDisplayLayer() : null;
5095
5251
  });
5096
- }
5097
5252
 
5098
- function MapNav(gui, ext, mouse) {
5099
- var wheel = new MouseWheel(mouse),
5100
- zoomTween = new Tween(Tween.sineInOut),
5101
- boxDrag = false,
5102
- zoomScaleMultiplier = 1,
5103
- inBtn, outBtn,
5104
- dragStartEvt,
5105
- _fx, _fy; // zoom foci, [0,1]
5253
+ mouse.on('leave', clearCoords);
5106
5254
 
5107
- this.setZoomFactor = function(k) {
5108
- zoomScaleMultiplier = k || 1;
5109
- };
5255
+ mouse.on('click', function(e) {
5256
+ if (!enabled) return;
5257
+ GUI.selectElement(readout.node());
5258
+ });
5110
5259
 
5111
- this.zoomToBbox = zoomToBbox;
5260
+ mouse.on('hover', onMouseChange);
5261
+ mouse.on('drag', onMouseChange, null, 10); // high priority so editor doesn't block propagation
5112
5262
 
5113
- if (gui.options.homeControl) {
5114
- gui.buttons.addButton("#home-icon").on('click', function() {
5115
- if (disabled()) return;
5116
- gui.dispatchEvent('map_reset');
5117
- });
5263
+ function onMouseChange(e) {
5264
+ if (!enabled) return;
5265
+ if (isOverMap(e)) {
5266
+ displayCoords(ext.translatePixelCoords(e.x, e.y));
5267
+ } else {
5268
+ clearCoords();
5269
+ }
5118
5270
  }
5119
5271
 
5120
- if (gui.options.zoomControl) {
5121
- inBtn = gui.buttons.addButton("#zoom-in-icon");
5122
- outBtn = gui.buttons.addButton("#zoom-out-icon");
5123
- initVariableClick(inBtn.node(), zoomIn);
5124
- initVariableClick(outBtn.node(), zoomOut);
5125
- ext.on('change', function() {
5126
- inBtn.classed('disabled', ext.scale() >= ext.maxScale());
5127
- });
5272
+ function displayCoords(p) {
5273
+ var decimals = internal.getBoundsPrecisionForDisplay(ext.getBounds().toArray());
5274
+ var str = internal.getRoundedCoordString(p, decimals);
5275
+ readout.text(str).show();
5128
5276
  }
5129
5277
 
5130
- gui.on('map_reset', function() {
5131
- ext.home();
5132
- });
5133
-
5134
- zoomTween.on('change', function(e) {
5135
- ext.zoomToExtent(e.value, _fx, _fy);
5136
- });
5278
+ function clearCoords() {
5279
+ readout.hide();
5280
+ }
5137
5281
 
5138
- mouse.on('dblclick', function(e) {
5139
- if (disabled()) return;
5140
- zoomByPct(getZoomInPct(), e.x / ext.width(), e.y / ext.height());
5141
- });
5282
+ function isOverMap(e) {
5283
+ return e.x >= 0 && e.y >= 0 && e.x < ext.width() && e.y < ext.height();
5284
+ }
5285
+ }
5142
5286
 
5143
- mouse.on('dragstart', function(e) {
5144
- if (disabled()) return;
5145
- if (!internal.layerHasGeometry(gui.model.getActiveLayer().layer)) return;
5146
- // zoomDrag = !!e.metaKey || !!e.ctrlKey; // meta is command on mac, windows key on windows
5147
- boxDrag = !!e.shiftKey;
5148
- if (boxDrag) {
5149
- dragStartEvt = e;
5150
- gui.dispatchEvent('box_drag_start');
5151
- }
5152
- });
5287
+ function getTimerFunction() {
5288
+ return typeof requestAnimationFrame == 'function' ?
5289
+ requestAnimationFrame : function(cb) {setTimeout(cb, 25);};
5290
+ }
5153
5291
 
5154
- mouse.on('drag', function(e) {
5155
- if (disabled()) return;
5156
- if (boxDrag) {
5157
- gui.dispatchEvent('box_drag', getBoxData(e));
5158
- } else {
5159
- ext.pan(e.dx, e.dy);
5160
- }
5161
- });
5292
+ function Timer() {
5293
+ var self = this,
5294
+ running = false,
5295
+ busy = false,
5296
+ tickTime, startTime, duration;
5162
5297
 
5163
- mouse.on('dragend', function(e) {
5164
- var bbox;
5165
- if (disabled()) return;
5166
- if (boxDrag) {
5167
- boxDrag = false;
5168
- gui.dispatchEvent('box_drag_end', getBoxData(e));
5169
- }
5170
- });
5298
+ this.start = function(ms) {
5299
+ var now = +new Date();
5300
+ duration = ms || Infinity;
5301
+ startTime = now;
5302
+ running = true;
5303
+ if (!busy) startTick(now);
5304
+ };
5171
5305
 
5172
- wheel.on('mousewheel', function(e) {
5173
- var tickFraction = 0.11; // 0.15; // fraction of zoom step per wheel event;
5174
- var k = 1 + (tickFraction * e.multiplier * zoomScaleMultiplier),
5175
- delta = e.direction > 0 ? k : 1 / k;
5176
- if (disabled()) return;
5177
- ext.zoomByPct(delta, e.x / ext.width(), e.y / ext.height());
5178
- });
5306
+ this.stop = function() {
5307
+ running = false;
5308
+ };
5179
5309
 
5180
- function swapElements(arr, i, j) {
5181
- var tmp = arr[i];
5182
- arr[i] = arr[j];
5183
- arr[j] = tmp;
5310
+ function startTick(now) {
5311
+ busy = true;
5312
+ tickTime = now;
5313
+ getTimerFunction()(onTick);
5184
5314
  }
5185
5315
 
5186
- function getBoxData(e) {
5187
- var pageBox = [e.pageX, e.pageY, dragStartEvt.pageX, dragStartEvt.pageY];
5188
- var mapBox = [e.x, e.y, dragStartEvt.x, dragStartEvt.y];
5189
- var tmp;
5190
- if (pageBox[0] > pageBox[2]) {
5191
- swapElements(pageBox, 0, 2);
5192
- swapElements(mapBox, 0, 2);
5193
- }
5194
- if (pageBox[1] > pageBox[3]) {
5195
- swapElements(pageBox, 1, 3);
5196
- swapElements(mapBox, 1, 3);
5316
+ function onTick() {
5317
+ var now = +new Date(),
5318
+ elapsed = now - startTime,
5319
+ pct = Math.min((elapsed + 10) / duration, 1),
5320
+ done = pct >= 1;
5321
+ if (!running) { // interrupted
5322
+ busy = false;
5323
+ return;
5197
5324
  }
5198
- return {
5199
- map_bbox: mapBox,
5200
- page_bbox: pageBox
5201
- };
5202
- }
5203
-
5204
- function disabled() {
5205
- return !!gui.options.disableNavigation;
5325
+ if (done) running = false;
5326
+ self.dispatchEvent('tick', {
5327
+ elapsed: elapsed,
5328
+ pct: pct,
5329
+ done: done,
5330
+ time: now,
5331
+ tickTime: now - tickTime
5332
+ });
5333
+ busy = false;
5334
+ if (running) startTick(now);
5206
5335
  }
5336
+ }
5207
5337
 
5208
- function zoomIn(e) {
5209
- if (disabled()) return;
5210
- zoomByPct(getZoomInPct(e.time), 0.5, 0.5);
5211
- }
5338
+ utils.inherit(Timer, EventDispatcher);
5212
5339
 
5213
- function zoomOut(e) {
5214
- if (disabled()) return;
5215
- zoomByPct(1/getZoomInPct(e.time), 0.5, 0.5);
5216
- }
5340
+ function Tween(ease) {
5341
+ var self = this,
5342
+ timer = new Timer(),
5343
+ start, end;
5217
5344
 
5218
- function getZoomInPct(clickTime) {
5219
- var minScale = 0.2,
5220
- maxScale = 4,
5221
- minTime = 100,
5222
- maxTime = 800,
5223
- time = utils.clamp(clickTime || 200, minTime, maxTime),
5224
- k = (time - minTime) / (maxTime - minTime),
5225
- scale = minScale + k * (maxScale - minScale);
5226
- return 1 + scale * zoomScaleMultiplier;
5227
- }
5345
+ timer.on('tick', onTick);
5228
5346
 
5229
- // @box Bounds with pixels from t,l corner of map area.
5230
- function zoomToBbox(bbox) {
5231
- var bounds = new Bounds(bbox),
5232
- pct = Math.max(bounds.width() / ext.width(), bounds.height() / ext.height()),
5233
- fx = bounds.centerX() / ext.width() * (1 + pct) - pct / 2,
5234
- fy = bounds.centerY() / ext.height() * (1 + pct) - pct / 2;
5235
- zoomByPct(1 / pct, fx, fy);
5236
- }
5347
+ this.start = function(a, b, duration) {
5348
+ start = a;
5349
+ end = b;
5350
+ timer.start(duration || 500);
5351
+ };
5237
5352
 
5238
- // @pct Change in scale (2 = 2x zoom)
5239
- // @fx, @fy zoom focus, [0, 1]
5240
- function zoomByPct(pct, fx, fy) {
5241
- var w = ext.getBounds().width();
5242
- _fx = fx;
5243
- _fy = fy;
5244
- zoomTween.start(w, w / pct, 400);
5353
+ function onTick(e) {
5354
+ var pct = ease ? ease(e.pct) : e.pct,
5355
+ val = end * pct + start * (1 - pct);
5356
+ self.dispatchEvent('change', {value: val});
5245
5357
  }
5246
5358
  }
5247
5359
 
5248
- function HighlightBox() {
5249
- var box = El('div').addClass('zoom-box').appendTo('body'),
5250
- show = box.show.bind(box), // original show() function
5251
- stroke = 2;
5252
- box.hide();
5253
- box.show = function(x1, y1, x2, y2) {
5254
- var w = Math.abs(x1 - x2),
5255
- h = Math.abs(y1 - y2);
5256
- box.css({
5257
- top: Math.min(y1, y2),
5258
- left: Math.min(x1, x2),
5259
- width: Math.max(w - stroke * 2, 1),
5260
- height: Math.max(h - stroke * 2, 1)
5261
- });
5262
- show();
5360
+ utils.inherit(Tween, EventDispatcher);
5361
+
5362
+ Tween.sineInOut = function(n) {
5363
+ return 0.5 - Math.cos(n * Math.PI) / 2;
5364
+ };
5365
+
5366
+ Tween.quadraticOut = function(n) {
5367
+ return 1 - Math.pow((1 - n), 2);
5368
+ };
5369
+
5370
+ function ElementPosition(ref) {
5371
+ var self = this,
5372
+ el = El(ref),
5373
+ pageX = 0,
5374
+ pageY = 0,
5375
+ width = 0,
5376
+ height = 0;
5377
+
5378
+ el.on('mouseover', update);
5379
+ if (window.onorientationchange) window.addEventListener('orientationchange', update);
5380
+ window.addEventListener('scroll', update);
5381
+ window.addEventListener('resize', update);
5382
+
5383
+ // trigger an update, e.g. when map container is resized
5384
+ this.update = function() {
5385
+ update();
5263
5386
  };
5264
- return box;
5265
- }
5266
5387
 
5267
- function SelectionTool(gui, ext, hit) {
5268
- var popup = gui.container.findChild('.selection-tool-options');
5269
- var box = new HighlightBox();
5270
- var _on = false;
5388
+ this.resize = function(w, h) {
5389
+ el.css('width', w).css('height', h);
5390
+ update();
5391
+ };
5271
5392
 
5272
- gui.addMode('selection_tool', turnOn, turnOff);
5393
+ this.width = function() { return width; };
5394
+ this.height = function() { return height; };
5395
+ this.position = function() {
5396
+ return {
5397
+ element: el.node(),
5398
+ pageX: pageX,
5399
+ pageY: pageY,
5400
+ width: width,
5401
+ height: height
5402
+ };
5403
+ };
5273
5404
 
5274
- gui.on('interaction_mode_change', function(e) {
5275
- if (e.mode === 'selection') {
5276
- gui.enterMode('selection_tool');
5277
- } else if (gui.getMode() == 'selection_tool') {
5278
- gui.clearMode();
5405
+ function update() {
5406
+ var div = el.node(),
5407
+ xy = getPageXY(div),
5408
+ w = div.clientWidth,
5409
+ h = div.clientHeight,
5410
+ x = xy.x,
5411
+ y = xy.y,
5412
+ resized = w != width || h != height,
5413
+ moved = x != pageX || y != pageY;
5414
+ if (resized || moved) {
5415
+ pageX = x;
5416
+ pageY = y;
5417
+ width = w;
5418
+ height = h;
5419
+ self.dispatchEvent('change', self.position());
5420
+ if (resized) {
5421
+ self.dispatchEvent('resize', self.position());
5422
+ }
5279
5423
  }
5280
- });
5424
+ }
5425
+ update();
5426
+ }
5281
5427
 
5282
- gui.on('box_drag', function(e) {
5283
- if (!_on) return;
5284
- var b = e.page_bbox;
5285
- box.show(b[0], b[1], b[2], b[3]);
5286
- updateSelection(e.map_bbox, true);
5287
- });
5428
+ utils.inherit(ElementPosition, EventDispatcher);
5288
5429
 
5289
- gui.on('box_drag_end', function(e) {
5290
- if (!_on) return;
5291
- box.hide();
5292
- updateSelection(e.map_bbox);
5293
- });
5430
+ function MouseWheelDirection() {
5431
+ var prevTime = 0;
5432
+ var getAvgDir;
5294
5433
 
5295
- function updateSelection(bboxPixels, transient) {
5296
- var bbox = bboxToCoords(bboxPixels);
5297
- var active = gui.model.getActiveLayer();
5298
- var ids = internal.findShapesIntersectingBBox(bbox, active.layer, active.dataset.arcs);
5299
- if (transient) {
5300
- hit.setTransientIds(ids);
5301
- } else if (ids.length) {
5302
- hit.addSelectionIds(ids);
5434
+ // returns 1, -1 or 0 to indicate direction of scroll
5435
+ // use avg of three values, as a buffer against single anomalous values
5436
+ return function(e, now) {
5437
+ var delta = e.wheelDelta || -e.detail || 0;
5438
+ var dir = delta > 0 && 1 || delta < 0 && -1 || 0;
5439
+ var avg;
5440
+ if (!getAvgDir || now - prevTime > 300) {
5441
+ getAvgDir = LimitedAverage(3); // reset if wheel has paused
5303
5442
  }
5304
- }
5443
+ prevTime = now;
5444
+ avg = getAvgDir(dir) || dir; // handle average == 0
5445
+ return avg > 0 && 1 || avg < 0 && -1 || 0;
5446
+ };
5447
+ }
5305
5448
 
5306
- function turnOn() {
5307
- _on = true;
5449
+ function LimitedAverage(maxSize) {
5450
+ var arr = [];
5451
+ return function(val) {
5452
+ var sum = 0,
5453
+ i = -1;
5454
+ arr.push(val);
5455
+ if (arr.length > maxSize) arr.shift();
5456
+ while (++i < arr.length) {
5457
+ sum += arr[i];
5458
+ }
5459
+ return sum / arr.length;
5460
+ };
5461
+ }
5462
+
5463
+ // @mouse: MouseArea object
5464
+ function MouseWheel(mouse) {
5465
+ var self = this,
5466
+ active = false,
5467
+ timer = new Timer().addEventListener('tick', onTick),
5468
+ sustainInterval = 150,
5469
+ fadeDelay = 70,
5470
+ eventTime = 0,
5471
+ getAverageRate = LimitedAverage(10),
5472
+ getWheelDirection = MouseWheelDirection(),
5473
+ wheelDirection;
5474
+
5475
+ if (window.onmousewheel !== undefined) { // ie, webkit
5476
+ window.addEventListener('mousewheel', handleWheel, {passive: false});
5477
+ } else { // firefox
5478
+ window.addEventListener('DOMMouseScroll', handleWheel);
5308
5479
  }
5309
5480
 
5310
- function bboxToCoords(bbox) {
5311
- var a = ext.translatePixelCoords(bbox[0], bbox[1]);
5312
- var b = ext.translatePixelCoords(bbox[2], bbox[3]);
5313
- return [a[0], b[1], b[0], a[1]];
5481
+ function updateSustainInterval(eventRate) {
5482
+ var fadeInterval = 80;
5483
+ fadeDelay = eventRate + 50; // adding a little extra time helps keep trackpad scrolling smooth in Firefox
5484
+ sustainInterval = fadeDelay + fadeInterval;
5314
5485
  }
5315
5486
 
5316
- function turnOff() {
5317
- reset();
5318
- _on = false;
5319
- if (gui.interaction.getMode() == 'selection') {
5320
- // mode change was not initiated by interactive menu -- turn off interactivity
5321
- gui.interaction.turnOff();
5487
+ function handleWheel(evt) {
5488
+ var now = +new Date();
5489
+ wheelDirection = getWheelDirection(evt, now);
5490
+ if (evt.ctrlKey) {
5491
+ // Prevent pinch-zoom in Chrome (doesn't work in Safari, though)
5492
+ evt.preventDefault();
5493
+ evt.stopImmediatePropagation();
5494
+ }
5495
+ if (!mouse.isOver()) return;
5496
+ if (wheelDirection === 0) {
5497
+ // first event may not have a direction, e.g. if 'smooth scrolling' is on
5498
+ return;
5499
+ }
5500
+ evt.preventDefault();
5501
+ if (!active) {
5502
+ active = true;
5503
+ self.dispatchEvent('mousewheelstart');
5504
+ } else {
5505
+ updateSustainInterval(getAverageRate(now - eventTime));
5322
5506
  }
5507
+ eventTime = now;
5508
+ timer.start(sustainInterval);
5323
5509
  }
5324
5510
 
5325
- function reset() {
5326
- popup.hide();
5327
- hit.clearSelection();
5328
- }
5329
-
5330
- function getIdsOpt() {
5331
- return hit.getSelectionIds().join(',');
5332
- }
5333
-
5334
- hit.on('change', function(e) {
5335
- if (e.mode != 'selection') return;
5336
- var ids = hit.getSelectionIds();
5337
- if (ids.length > 0) {
5338
- // enter this mode when we're ready to show the selection options
5339
- // (this closes any other active mode, e.g. box_tool)
5340
- gui.enterMode('selection_tool');
5341
- popup.show();
5511
+ function onTick(evt) {
5512
+ var tickInterval = evt.time - eventTime,
5513
+ multiplier = evt.tickTime / 25,
5514
+ fadeFactor = 0,
5515
+ obj;
5516
+ if (tickInterval > fadeDelay) {
5517
+ fadeFactor = Math.min(1, (tickInterval - fadeDelay) / (sustainInterval - fadeDelay));
5518
+ }
5519
+ if (evt.done) {
5520
+ active = false;
5342
5521
  } else {
5343
- popup.hide();
5522
+ if (fadeFactor > 0) {
5523
+ // Decelerate towards the end of the sustain interval (for smoother zooming)
5524
+ multiplier *= Tween.quadraticOut(1 - fadeFactor);
5525
+ }
5526
+ obj = utils.extend({direction: wheelDirection, multiplier: multiplier}, mouse.mouseData());
5527
+ self.dispatchEvent('mousewheel', obj);
5344
5528
  }
5345
- });
5346
-
5347
- new SimpleButton(popup.findChild('.delete-btn')).on('click', function() {
5348
- var cmd = '-filter invert ids=' + getIdsOpt();
5349
- runCommand(cmd);
5350
- });
5351
-
5352
- new SimpleButton(popup.findChild('.filter-btn')).on('click', function() {
5353
- var cmd = '-filter ids=' + getIdsOpt();
5354
- runCommand(cmd);
5355
- });
5356
-
5357
- new SimpleButton(popup.findChild('.split-btn')).on('click', function() {
5358
- var cmd = '-split ids=' + getIdsOpt();
5359
- runCommand(cmd);
5360
- });
5361
-
5362
- new SimpleButton(popup.findChild('.cancel-btn')).on('click', function() {
5363
- hit.clearSelection();
5364
- });
5365
-
5366
- function runCommand(cmd) {
5367
- popup.hide();
5368
- if (gui.console) gui.console.runMapshaperCommands(cmd, function(err) {
5369
- reset();
5370
- });
5371
5529
  }
5372
5530
  }
5373
5531
 
5374
- // toNext, toPrev: trigger functions for switching between multiple records
5375
- function Popup(gui, toNext, toPrev) {
5376
- var self = new EventDispatcher();
5377
- var parent = gui.container.findChild('.mshp-main-map');
5378
- var el = El('div').addClass('popup').appendTo(parent).hide();
5379
- var content = El('div').addClass('popup-content').appendTo(el);
5380
- // multi-hit display and navigation
5381
- var tab = El('div').addClass('popup-tab').appendTo(el).hide();
5382
- var nav = El('div').addClass('popup-nav').appendTo(tab);
5383
- var prevLink = El('span').addClass('popup-nav-arrow colored-text').appendTo(nav).text('◀');
5384
- var navInfo = El('span').addClass('popup-nav-info').appendTo(nav);
5385
- var nextLink = El('span').addClass('popup-nav-arrow colored-text').appendTo(nav).text('▶');
5386
- var refresh = null;
5387
- var currId = -1;
5532
+ utils.inherit(MouseWheel, EventDispatcher);
5388
5533
 
5389
- el.addClass('rollover'); // used as a sentinel for the hover function
5390
5534
 
5391
- nextLink.on('click', toNext);
5392
- prevLink.on('click', toPrev);
5393
- gui.on('popup-needs-refresh', function() {
5394
- if (refresh) refresh();
5395
- });
5535
+ function MouseArea(element, pos) {
5536
+ var _pos = pos || new ElementPosition(element),
5537
+ _areaPos = _pos.position(),
5538
+ _self = this,
5539
+ _dragging = false,
5540
+ _isOver = false,
5541
+ _disabled = false,
5542
+ _prevEvt,
5543
+ _downEvt;
5396
5544
 
5397
- self.show = function(id, ids, lyr, pinned) {
5398
- var table = lyr.data; // table can be null (e.g. if layer has no attribute data)
5399
- var editable = pinned && gui.interaction.getMode() == 'data';
5400
- var maxHeight = parent.node().clientHeight - 36;
5401
- currId = id;
5402
- // stash a function for refreshing the current popup when data changes
5403
- // while the popup is being displayed (e.g. while dragging a label)
5404
- refresh = function() {
5405
- var rec = table && (editable ? table.getRecordAt(id) : table.getReadOnlyRecordAt(id)) || {};
5406
- render(content, rec, table, editable);
5407
- };
5408
- refresh();
5409
- if (ids && ids.length > 1) {
5410
- showNav(id, ids, pinned);
5411
- } else {
5412
- tab.hide();
5413
- }
5414
- el.show();
5415
- if (content.node().clientHeight > maxHeight) {
5416
- content.css('height:' + maxHeight + 'px');
5417
- }
5418
- };
5545
+ _pos.on('change', function() {_areaPos = _pos.position();});
5546
+ // TODO: think about touch events
5547
+ document.addEventListener('mousemove', onMouseMove);
5548
+ document.addEventListener('mousedown', onMouseDown);
5549
+ document.addEventListener('mouseup', onMouseUp);
5550
+ element.addEventListener('mouseover', onAreaEnter);
5551
+ element.addEventListener('mousemove', onAreaEnter);
5552
+ element.addEventListener('mouseout', onAreaOut);
5553
+ element.addEventListener('mousedown', onAreaDown);
5554
+ element.addEventListener('dblclick', onAreaDblClick);
5419
5555
 
5420
- self.hide = function() {
5421
- if (!isOpen()) return;
5422
- refresh = null;
5423
- currId = -1;
5424
- // make sure any pending edits are made before re-rendering popup
5425
- GUI.blurActiveElement(); // this should be more selective -- could cause a glitch if typing in console
5426
- content.empty();
5427
- content.node().removeAttribute('style'); // remove inline height
5428
- el.hide();
5556
+ this.enable = function() {
5557
+ if (!_disabled) return;
5558
+ _disabled = false;
5559
+ element.style.pointerEvents = 'auto';
5429
5560
  };
5430
5561
 
5431
- return self;
5432
-
5433
- function isOpen() {
5434
- return el.visible();
5435
- }
5562
+ this.stopDragging = function() {
5563
+ if (_downEvt) {
5564
+ if (_dragging) stopDragging(_downEvt);
5565
+ _downEvt = null;
5566
+ }
5567
+ };
5436
5568
 
5437
- function showNav(id, ids, pinned) {
5438
- var num = ids.indexOf(id) + 1;
5439
- navInfo.text(' ' + num + ' / ' + ids.length + ' ');
5440
- nextLink.css('display', pinned ? 'inline-block' : 'none');
5441
- prevLink.css('display', pinned && ids.length > 2 ? 'inline-block' : 'none');
5442
- tab.show();
5443
- }
5569
+ this.disable = function() {
5570
+ if (_disabled) return;
5571
+ _disabled = true;
5572
+ if (_isOver) onAreaOut();
5573
+ this.stopDragging();
5574
+ element.style.pointerEvents = 'none';
5575
+ };
5444
5576
 
5445
- function render(el, rec, table, editable) {
5446
- var tableEl = El('table').addClass('selectable'),
5447
- rows = 0;
5448
- // self.hide(); // clean up if panel is already open
5449
- el.empty(); // clean up if panel is already open
5450
- utils.forEachProperty(rec, function(v, k) {
5451
- var type;
5452
- // missing GeoJSON fields are set to undefined on import; skip these
5453
- if (v !== undefined) {
5454
- type = getFieldType(v, k, table);
5455
- renderRow(tableEl, rec, k, type, editable);
5456
- rows++;
5457
- }
5458
- });
5577
+ this.isOver = function() {
5578
+ return _isOver;
5579
+ };
5459
5580
 
5460
- if (rows > 0) {
5461
- tableEl.appendTo(el);
5581
+ this.isDown = function() {
5582
+ return !!_downEvt;
5583
+ };
5462
5584
 
5463
- tableEl.on('copy', function(e) {
5464
- // remove leading or trailing tabs that sometimes get copied when
5465
- // selecting from a table
5466
- var pasted = window.getSelection().toString();
5467
- var cleaned = pasted.replace(/^\t/, '').replace(/\t$/, '');
5468
- if (pasted != cleaned && !window.clipboardData) { // ignore ie
5469
- (e.clipboardData || e.originalEvent.clipboardData).setData("text", cleaned);
5470
- e.preventDefault(); // don't copy original string with tabs
5471
- }
5472
- });
5585
+ this.mouseData = function() {
5586
+ return utils.extend({}, _prevEvt);
5587
+ };
5473
5588
 
5474
- } else {
5475
- // Some individual features can have undefined values for some or all of
5476
- // their data properties (properties are set to undefined when an input JSON file
5477
- // has inconsistent fields, or after force-merging layers with inconsistent fields).
5478
- el.html(utils.format('<div class="note">This %s is missing attribute data.</div>',
5479
- table && table.getFields().length > 0 ? 'feature': 'layer'));
5480
- }
5589
+ function onAreaDown(e) {
5590
+ e.preventDefault(); // prevent text selection cursor on drag
5481
5591
  }
5482
5592
 
5483
- function renderRow(table, rec, key, type, editable) {
5484
- var rowHtml = '<td class="field-name">%s</td><td><span class="value">%s</span> </td>';
5485
- var val = rec[key];
5486
- var str = formatInspectorValue(val, type);
5487
- var cell = El('tr')
5488
- .appendTo(table)
5489
- .html(utils.format(rowHtml, key, utils.htmlEscape(str)))
5490
- .findChild('.value');
5491
- setFieldClass(cell, val, type);
5492
- if (editable) {
5493
- editItem(cell, rec, key, type);
5593
+ function onAreaEnter() {
5594
+ if (!_isOver) {
5595
+ _isOver = true;
5596
+ _self.dispatchEvent('enter');
5494
5597
  }
5495
5598
  }
5496
5599
 
5497
- function setFieldClass(el, val, type) {
5498
- var isNum = type ? type == 'number' : utils.isNumber(val);
5499
- var isNully = val === undefined || val === null || val !== val;
5500
- var isEmpty = val === '';
5501
- el.classed('num-field', isNum);
5502
- el.classed('object-field', type == 'object');
5503
- el.classed('null-value', isNully);
5504
- el.classed('empty', isEmpty);
5600
+ function onAreaOut() {
5601
+ _isOver = false;
5602
+ _self.dispatchEvent('leave');
5505
5603
  }
5506
5604
 
5507
- function editItem(el, rec, key, type) {
5508
- var input = new ClickText2(el),
5509
- strval = formatInspectorValue(rec[key], type),
5510
- parser = getInputParser(type);
5511
- el.parent().addClass('editable-cell');
5512
- el.addClass('colored-text dot-underline');
5513
- input.on('change', function(e) {
5514
- var val2 = parser(input.value()),
5515
- strval2 = formatInspectorValue(val2, type);
5516
- if (strval == strval2) {
5517
- // contents unchanged
5518
- } else if (val2 === null && type != 'object') { // allow null objects
5519
- // invalid value; revert to previous value
5520
- input.value(strval);
5521
- } else {
5522
- // field content has changed
5523
- strval = strval2;
5524
- rec[key] = val2;
5525
- input.value(strval);
5526
- setFieldClass(el, val2, type);
5527
- self.dispatchEvent('update', {field: key, value: val2, id: currId});
5605
+ function onMouseUp(e) {
5606
+ var evt = procMouseEvent(e),
5607
+ elapsed, dx, dy;
5608
+ if (_dragging) {
5609
+ stopDragging(evt);
5610
+ }
5611
+ if (_downEvt) {
5612
+ elapsed = evt.time - _downEvt.time;
5613
+ dx = evt.pageX - _downEvt.pageX;
5614
+ dy = evt.pageY - _downEvt.pageY;
5615
+ if (_isOver && elapsed < 500 && Math.sqrt(dx * dx + dy * dy) < 6) {
5616
+ _self.dispatchEvent('click', evt);
5528
5617
  }
5529
- });
5618
+ _downEvt = null;
5619
+ }
5530
5620
  }
5531
- }
5532
5621
 
5533
- function formatInspectorValue(val, type) {
5534
- var str;
5535
- if (type == 'date') {
5536
- str = utils.formatDateISO(val);
5537
- } else if (type == 'object') {
5538
- str = val ? JSON.stringify(val) : "";
5539
- } else {
5540
- str = String(val);
5622
+ function stopDragging(evt) {
5623
+ _dragging = false;
5624
+ _self.dispatchEvent('dragend', evt);
5541
5625
  }
5542
- return str;
5543
- }
5544
5626
 
5545
- var inputParsers = {
5546
- date: function(raw) {
5547
- var d = new Date(raw);
5548
- return isNaN(+d) ? null : d;
5549
- },
5550
- string: function(raw) {
5551
- return raw;
5552
- },
5553
- number: function(raw) {
5554
- var val = Number(raw);
5555
- if (raw == 'NaN') {
5556
- val = NaN;
5557
- } else if (isNaN(val)) {
5558
- val = null;
5627
+ function onMouseDown(e) {
5628
+ if (e.button != 2 && e.which != 3) { // ignore right-click
5629
+ _downEvt = procMouseEvent(e);
5559
5630
  }
5560
- return val;
5561
- },
5562
- object: function(raw) {
5563
- var val = null;
5564
- try {
5565
- val = JSON.parse(raw);
5566
- } catch(e) {}
5567
- return val;
5568
- },
5569
- boolean: function(raw) {
5570
- var val = null;
5571
- if (raw == 'true') {
5572
- val = true;
5573
- } else if (raw == 'false') {
5574
- val = false;
5631
+ }
5632
+
5633
+ function onMouseMove(e) {
5634
+ var evt = procMouseEvent(e);
5635
+ if (!_dragging && _downEvt && _downEvt.hover) {
5636
+ _dragging = true;
5637
+ _self.dispatchEvent('dragstart', evt);
5638
+ }
5639
+ if (evt.dx === 0 && evt.dy === 0) return; // seen in Chrome
5640
+ if (_dragging) {
5641
+ var obj = {
5642
+ dragX: evt.pageX - _downEvt.pageX,
5643
+ dragY: evt.pageY - _downEvt.pageY
5644
+ };
5645
+ _self.dispatchEvent('drag', utils.extend(obj, evt));
5646
+ } else {
5647
+ _self.dispatchEvent('hover', evt);
5575
5648
  }
5576
- return val;
5577
- },
5578
- multiple: function(raw) {
5579
- var val = Number(raw);
5580
- return isNaN(val) ? raw : val;
5581
5649
  }
5582
- };
5583
5650
 
5584
- function getInputParser(type) {
5585
- return inputParsers[type || 'multiple'];
5586
- }
5651
+ function onAreaDblClick(e) {
5652
+ if (_isOver) _self.dispatchEvent('dblclick', procMouseEvent(e));
5653
+ }
5587
5654
 
5588
- function getFieldType(val, key, table) {
5589
- // if a field has a null value, look at entire column to identify type
5590
- return internal.getValueType(val) || internal.getColumnType(key, table.getRecords());
5655
+ function procMouseEvent(e) {
5656
+ var pageX = e.pageX,
5657
+ pageY = e.pageY,
5658
+ prev = _prevEvt;
5659
+ _prevEvt = {
5660
+ originalEvent: e,
5661
+ shiftKey: e.shiftKey,
5662
+ metaKey: e.metaKey,
5663
+ ctrlKey: e.ctrlKey,
5664
+ time: +new Date(),
5665
+ pageX: pageX,
5666
+ pageY: pageY,
5667
+ hover: _isOver,
5668
+ x: pageX - _areaPos.pageX,
5669
+ y: pageY - _areaPos.pageY,
5670
+ dx: prev ? pageX - prev.pageX : 0,
5671
+ dy: prev ? pageY - prev.pageY : 0
5672
+ };
5673
+ return _prevEvt;
5674
+ }
5591
5675
  }
5592
5676
 
5593
- function InspectionControl2(gui, hit) {
5594
- var _popup = new Popup(gui, hit.getSwitchTrigger(1), hit.getSwitchTrigger(-1));
5595
- var _self = new EventDispatcher();
5677
+ utils.inherit(MouseArea, EventDispatcher);
5596
5678
 
5597
- gui.on('interaction_mode_change', function(e) {
5598
- if (e.mode == 'off') {
5599
- inspect(-1); // clear the popup
5600
- }
5601
- });
5679
+ function initVariableClick(node, cb) {
5680
+ var downEvent = null;
5681
+ var downTime = 0;
5602
5682
 
5603
- _popup.on('update', function(e) {
5604
- _self.dispatchEvent('data_change', e.data); // let map know which field has changed
5683
+ node.addEventListener('mousedown', function(e) {
5684
+ downEvent = e;
5685
+ downTime = Date.now();
5605
5686
  });
5606
5687
 
5607
- hit.on('change', function(e) {
5608
- var ids;
5609
- if (!inspecting()) return;
5610
- ids = e.mode == 'selection' ? null : e.ids;
5611
- inspect(e.id, e.pinned, ids);
5688
+ node.addEventListener('mouseup', function(upEvent) {
5689
+ if (!downEvent) return;
5690
+ var shift = Math.abs(downEvent.pageX - upEvent.pageX) +
5691
+ Math.abs(downEvent.pageY - upEvent.pageY);
5692
+ var elapsed = Date.now() - downTime;
5693
+ if (shift > 5 || elapsed > 1000) return;
5694
+ downEvent = null;
5695
+ cb({time: elapsed});
5612
5696
  });
5697
+ }
5613
5698
 
5614
- // id: Id of a feature in the active layer, or -1
5615
- function inspect(id, pin, ids) {
5616
- var target = hit.getHitTarget();
5617
- if (id > -1 && inspecting() && target && target.layer) {
5618
- _popup.show(id, ids, target.layer, pin);
5619
- } else {
5620
- _popup.hide();
5621
- }
5699
+ function MapNav(gui, ext, mouse) {
5700
+ var wheel = new MouseWheel(mouse),
5701
+ zoomTween = new Tween(Tween.sineInOut),
5702
+ boxDrag = false,
5703
+ zoomScaleMultiplier = 1,
5704
+ inBtn, outBtn,
5705
+ dragStartEvt,
5706
+ _fx, _fy; // zoom foci, [0,1]
5707
+
5708
+ this.setZoomFactor = function(k) {
5709
+ zoomScaleMultiplier = k || 1;
5710
+ };
5711
+
5712
+ this.zoomToBbox = zoomToBbox;
5713
+
5714
+ if (gui.options.homeControl) {
5715
+ gui.buttons.addButton("#home-icon").on('click', function() {
5716
+ if (disabled()) return;
5717
+ gui.dispatchEvent('map_reset');
5718
+ });
5622
5719
  }
5623
5720
 
5624
- // does the attribute inspector appear on rollover
5625
- function inspecting() {
5626
- return gui.interaction && gui.interaction.getMode() != 'off';
5721
+ if (gui.options.zoomControl) {
5722
+ inBtn = gui.buttons.addButton("#zoom-in-icon");
5723
+ outBtn = gui.buttons.addButton("#zoom-out-icon");
5724
+ initVariableClick(inBtn.node(), zoomIn);
5725
+ initVariableClick(outBtn.node(), zoomOut);
5726
+ ext.on('change', function() {
5727
+ inBtn.classed('disabled', ext.scale() >= ext.maxScale());
5728
+ });
5627
5729
  }
5628
5730
 
5629
- return _self;
5630
- }
5731
+ gui.on('map_reset', function() {
5732
+ ext.home();
5733
+ });
5631
5734
 
5632
- // Test if map should be re-framed to show updated layer
5633
- function mapNeedsReset(newBounds, prevBounds, viewportBounds, flags) {
5634
- var viewportPct = getIntersectionPct(newBounds, viewportBounds);
5635
- var contentPct = getIntersectionPct(viewportBounds, newBounds);
5636
- var boundsChanged = !prevBounds.equals(newBounds);
5637
- var inView = newBounds.intersects(viewportBounds);
5638
- var areaChg = newBounds.area() / prevBounds.area();
5639
- var chgThreshold = flags.proj ? 1e3 : 1e8;
5640
- // don't reset if layer extent hasn't changed
5641
- if (!boundsChanged) return false;
5642
- // reset if layer is out-of-view
5643
- if (!inView) return true;
5644
- // reset if content is mostly offscreen
5645
- if (viewportPct < 0.3 && contentPct < 0.9) return true;
5646
- // reset if content bounds have changed a lot (e.g. after projection)
5647
- if (areaChg > chgThreshold || areaChg < 1/chgThreshold) return true;
5648
- return false;
5649
- }
5735
+ zoomTween.on('change', function(e) {
5736
+ ext.zoomToExtent(e.value, _fx, _fy);
5737
+ });
5650
5738
 
5651
- // Returns proportion of bb2 occupied by bb1
5652
- function getIntersectionPct(bb1, bb2) {
5653
- return getBoundsIntersection(bb1, bb2).area() / bb2.area() || 0;
5654
- }
5739
+ mouse.on('dblclick', function(e) {
5740
+ if (disabled()) return;
5741
+ zoomByPct(getZoomInPct(), e.x / ext.width(), e.y / ext.height());
5742
+ });
5655
5743
 
5656
- function getBoundsIntersection(a, b) {
5657
- var c = new Bounds();
5658
- if (a.intersects(b)) {
5659
- c.setBounds(Math.max(a.xmin, b.xmin), Math.max(a.ymin, b.ymin),
5660
- Math.min(a.xmax, b.xmax), Math.min(a.ymax, b.ymax));
5661
- }
5662
- return c;
5663
- }
5744
+ mouse.on('dragstart', function(e) {
5745
+ if (disabled()) return;
5746
+ if (!internal.layerHasGeometry(gui.model.getActiveLayer().layer)) return;
5747
+ // zoomDrag = !!e.metaKey || !!e.ctrlKey; // meta is command on mac, windows key on windows
5748
+ boxDrag = !!e.shiftKey;
5749
+ if (boxDrag) {
5750
+ dragStartEvt = e;
5751
+ gui.dispatchEvent('box_drag_start');
5752
+ }
5753
+ });
5664
5754
 
5665
- function isMultilineLabel(textNode) {
5666
- return textNode.childNodes.length > 1;
5667
- }
5755
+ mouse.on('drag', function(e) {
5756
+ if (disabled()) return;
5757
+ if (boxDrag) {
5758
+ gui.dispatchEvent('box_drag', getBoxData(e));
5759
+ } else {
5760
+ ext.pan(e.dx, e.dy);
5761
+ }
5762
+ });
5763
+
5764
+ mouse.on('dragend', function(e) {
5765
+ var bbox;
5766
+ if (disabled()) return;
5767
+ if (boxDrag) {
5768
+ boxDrag = false;
5769
+ gui.dispatchEvent('box_drag_end', getBoxData(e));
5770
+ }
5771
+ });
5772
+
5773
+ wheel.on('mousewheel', function(e) {
5774
+ var tickFraction = 0.11; // 0.15; // fraction of zoom step per wheel event;
5775
+ var k = 1 + (tickFraction * e.multiplier * zoomScaleMultiplier),
5776
+ delta = e.direction > 0 ? k : 1 / k;
5777
+ if (disabled()) return;
5778
+ ext.zoomByPct(delta, e.x / ext.width(), e.y / ext.height());
5779
+ });
5668
5780
 
5669
- function toggleTextAlign(textNode, rec) {
5670
- var curr = rec['text-anchor'] || 'middle';
5671
- var value = curr == 'middle' && 'start' || curr == 'start' && 'end' || 'middle';
5672
- updateTextAnchor(value, textNode, rec);
5673
- }
5781
+ function swapElements(arr, i, j) {
5782
+ var tmp = arr[i];
5783
+ arr[i] = arr[j];
5784
+ arr[j] = tmp;
5785
+ }
5674
5786
 
5675
- // Set an attribute on a <text> node and any child <tspan> elements
5676
- // (mapshaper's svg labels require tspans to have the same x and dx values
5677
- // as the enclosing text node)
5678
- function setMultilineAttribute(textNode, name, value) {
5679
- var n = textNode.childNodes.length;
5680
- var i = -1;
5681
- var child;
5682
- textNode.setAttribute(name, value);
5683
- while (++i < n) {
5684
- child = textNode.childNodes[i];
5685
- if (child.tagName == 'tspan') {
5686
- child.setAttribute(name, value);
5787
+ function getBoxData(e) {
5788
+ var pageBox = [e.pageX, e.pageY, dragStartEvt.pageX, dragStartEvt.pageY];
5789
+ var mapBox = [e.x, e.y, dragStartEvt.x, dragStartEvt.y];
5790
+ var tmp;
5791
+ if (pageBox[0] > pageBox[2]) {
5792
+ swapElements(pageBox, 0, 2);
5793
+ swapElements(mapBox, 0, 2);
5794
+ }
5795
+ if (pageBox[1] > pageBox[3]) {
5796
+ swapElements(pageBox, 1, 3);
5797
+ swapElements(mapBox, 1, 3);
5687
5798
  }
5799
+ return {
5800
+ map_bbox: mapBox,
5801
+ page_bbox: pageBox
5802
+ };
5688
5803
  }
5689
- }
5690
5804
 
5691
- function findSvgRoot(el) {
5692
- while (el && el.tagName != 'html' && el.tagName != 'body') {
5693
- if (el.tagName == 'svg') return el;
5694
- el = el.parentNode;
5805
+ function disabled() {
5806
+ return !!gui.options.disableNavigation;
5695
5807
  }
5696
- return null;
5697
- }
5698
-
5699
- // p: pixel coordinates of label anchor
5700
- function autoUpdateTextAnchor(textNode, rec, p) {
5701
- var svg = findSvgRoot(textNode);
5702
- var rect = textNode.getBoundingClientRect();
5703
- var labelCenterX = rect.left - svg.getBoundingClientRect().left + rect.width / 2;
5704
- var xpct = (labelCenterX - p[0]) / rect.width; // offset of label center from anchor center
5705
- var value = xpct < -0.25 && 'end' || xpct > 0.25 && 'start' || 'middle';
5706
- updateTextAnchor(value, textNode, rec);
5707
- }
5708
-
5709
- // @value: optional position to set; if missing, auto-set
5710
- function updateTextAnchor(value, textNode, rec) {
5711
- var rect = textNode.getBoundingClientRect();
5712
- var width = rect.width;
5713
- var curr = rec['text-anchor'] || 'middle';
5714
- var xshift = 0;
5715
5808
 
5716
- // console.log("anchor() curr:", curr, "xpct:", xpct, "left:", rect.left, "anchorX:", anchorX, "targ:", targ, "dx:", xshift)
5717
- if (curr == 'middle' && value == 'end' || curr == 'start' && value == 'middle') {
5718
- xshift = width / 2;
5719
- } else if (curr == 'middle' && value == 'start' || curr == 'end' && value == 'middle') {
5720
- xshift = -width / 2;
5721
- } else if (curr == 'start' && value == 'end') {
5722
- xshift = width;
5723
- } else if (curr == 'end' && value == 'start') {
5724
- xshift = -width;
5809
+ function zoomIn(e) {
5810
+ if (disabled()) return;
5811
+ zoomByPct(getZoomInPct(e.time), 0.5, 0.5);
5725
5812
  }
5726
- if (xshift) {
5727
- rec['text-anchor'] = value;
5728
- applyDelta(rec, 'dx', Math.round(xshift));
5813
+
5814
+ function zoomOut(e) {
5815
+ if (disabled()) return;
5816
+ zoomByPct(1/getZoomInPct(e.time), 0.5, 0.5);
5729
5817
  }
5730
- }
5731
5818
 
5732
- // handle either numeric strings or numbers in fields
5733
- function applyDelta(rec, key, delta) {
5734
- var currVal = rec[key];
5735
- var isString = utils.isString(currVal);
5736
- var newVal = (+currVal + delta) || 0;
5737
- rec[key] = isString ? String(newVal) : newVal;
5738
- }
5819
+ function getZoomInPct(clickTime) {
5820
+ var minScale = 0.2,
5821
+ maxScale = 4,
5822
+ minTime = 100,
5823
+ maxTime = 800,
5824
+ time = utils.clamp(clickTime || 200, minTime, maxTime),
5825
+ k = (time - minTime) / (maxTime - minTime),
5826
+ scale = minScale + k * (maxScale - minScale);
5827
+ return 1 + scale * zoomScaleMultiplier;
5828
+ }
5739
5829
 
5740
- function getDisplayCoordsById(id, layer, ext) {
5741
- var coords = getPointCoordsById(id, layer);
5742
- return ext.translateCoords(coords[0], coords[1]);
5743
- }
5830
+ // @box Bounds with pixels from t,l corner of map area.
5831
+ function zoomToBbox(bbox) {
5832
+ var bounds = new Bounds(bbox),
5833
+ pct = Math.max(bounds.width() / ext.width(), bounds.height() / ext.height()),
5834
+ fx = bounds.centerX() / ext.width() * (1 + pct) - pct / 2,
5835
+ fy = bounds.centerY() / ext.height() * (1 + pct) - pct / 2;
5836
+ zoomByPct(1 / pct, fx, fy);
5837
+ }
5744
5838
 
5745
- function getPointCoordsById(id, layer) {
5746
- var coords = layer && layer.geometry_type == 'point' && layer.shapes[id];
5747
- if (!coords || coords.length != 1) {
5748
- return null;
5839
+ // @pct Change in scale (2 = 2x zoom)
5840
+ // @fx, @fy zoom focus, [0, 1]
5841
+ function zoomByPct(pct, fx, fy) {
5842
+ var w = ext.getBounds().width();
5843
+ _fx = fx;
5844
+ _fy = fy;
5845
+ zoomTween.start(w, w / pct, 400);
5749
5846
  }
5750
- return coords[0];
5751
5847
  }
5752
5848
 
5753
- function translateDeltaDisplayCoords(dx, dy, ext) {
5754
- var a = ext.translatePixelCoords(0, 0);
5755
- var b = ext.translatePixelCoords(dx, dy);
5756
- return [b[0] - a[0], b[1] - a[1]];
5849
+ function HighlightBox() {
5850
+ var box = El('div').addClass('zoom-box').appendTo('body'),
5851
+ show = box.show.bind(box), // original show() function
5852
+ stroke = 2;
5853
+ box.hide();
5854
+ box.show = function(x1, y1, x2, y2) {
5855
+ var w = Math.abs(x1 - x2),
5856
+ h = Math.abs(y1 - y2);
5857
+ box.css({
5858
+ top: Math.min(y1, y2),
5859
+ left: Math.min(x1, x2),
5860
+ width: Math.max(w - stroke * 2, 1),
5861
+ height: Math.max(h - stroke * 2, 1)
5862
+ });
5863
+ show();
5864
+ };
5865
+ return box;
5757
5866
  }
5758
5867
 
5868
+ function SelectionTool(gui, ext, hit) {
5869
+ var popup = gui.container.findChild('.selection-tool-options');
5870
+ var box = new HighlightBox();
5871
+ var _on = false;
5759
5872
 
5760
- function SymbolDragging2(gui, ext, hit) {
5761
- // var targetTextNode; // text node currently being dragged
5762
- var dragging = false;
5763
- var activeRecord;
5764
- var activeId = -1;
5765
- var self = new EventDispatcher();
5766
- var activeVertexIds = null; // for vertex dragging
5873
+ gui.addMode('selection_tool', turnOn, turnOff);
5767
5874
 
5768
- initDragging();
5875
+ gui.on('interaction_mode_change', function(e) {
5876
+ if (e.mode === 'selection') {
5877
+ gui.enterMode('selection_tool');
5878
+ } else if (gui.getMode() == 'selection_tool') {
5879
+ gui.clearMode();
5880
+ }
5881
+ });
5769
5882
 
5770
- return self;
5883
+ gui.on('box_drag', function(e) {
5884
+ if (!_on) return;
5885
+ var b = e.page_bbox;
5886
+ box.show(b[0], b[1], b[2], b[3]);
5887
+ updateSelection(e.map_bbox, true);
5888
+ });
5771
5889
 
5772
- function labelEditingEnabled() {
5773
- return gui.interaction && gui.interaction.getMode() == 'labels' ? true : false;
5890
+ gui.on('box_drag_end', function(e) {
5891
+ if (!_on) return;
5892
+ box.hide();
5893
+ updateSelection(e.map_bbox);
5894
+ });
5895
+
5896
+ function updateSelection(bboxPixels, transient) {
5897
+ var bbox = bboxToCoords(bboxPixels);
5898
+ var active = gui.model.getActiveLayer();
5899
+ var ids = internal.findShapesIntersectingBBox(bbox, active.layer, active.dataset.arcs);
5900
+ if (transient) {
5901
+ hit.setTransientIds(ids);
5902
+ } else if (ids.length) {
5903
+ hit.addSelectionIds(ids);
5904
+ }
5774
5905
  }
5775
5906
 
5776
- function locationEditingEnabled() {
5777
- return gui.interaction && gui.interaction.getMode() == 'location' ? true : false;
5907
+ function turnOn() {
5908
+ _on = true;
5778
5909
  }
5779
5910
 
5780
- function vertexEditingEnabled() {
5781
- return gui.interaction && gui.interaction.getMode() == 'vertices' ? true : false;
5911
+ function bboxToCoords(bbox) {
5912
+ var a = ext.translatePixelCoords(bbox[0], bbox[1]);
5913
+ var b = ext.translatePixelCoords(bbox[2], bbox[3]);
5914
+ return [a[0], b[1], b[0], a[1]];
5782
5915
  }
5783
5916
 
5784
- // update symbol by setting attributes
5785
- function updateSymbol(node, d) {
5786
- var a = d['text-anchor'];
5787
- if (a) node.setAttribute('text-anchor', a);
5788
- setMultilineAttribute(node, 'dx', d.dx || 0);
5789
- node.setAttribute('y', d.dy || 0);
5917
+ function turnOff() {
5918
+ reset();
5919
+ _on = false;
5920
+ if (gui.interaction.getMode() == 'selection') {
5921
+ // mode change was not initiated by interactive menu -- turn off interactivity
5922
+ gui.interaction.turnOff();
5923
+ }
5790
5924
  }
5791
5925
 
5792
- // update symbol by re-rendering it
5793
- function updateSymbol2(node, d, id) {
5794
- var o = internal.svg.importStyledLabel(d); // TODO: symbol support
5795
- var activeLayer = hit.getHitTarget().layer;
5796
- var xy = activeLayer.shapes[id][0];
5797
- var g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
5798
- var node2;
5799
- o.properties.transform = getSvgSymbolTransform(xy, ext);
5800
- o.properties['data-id'] = id;
5801
- // o.properties['class'] = 'selected';
5802
- g.innerHTML = internal.svg.stringify(o);
5803
- node2 = g.firstChild;
5804
- node.parentNode.replaceChild(node2, node);
5805
- gui.dispatchEvent('popup-needs-refresh');
5806
- return node2;
5926
+ function reset() {
5927
+ popup.hide();
5928
+ hit.clearSelection();
5807
5929
  }
5808
5930
 
5809
- function initDragging() {
5810
- var downEvt;
5811
- var eventPriority = 1;
5931
+ function getIdsOpt() {
5932
+ return hit.getSelectionIds().join(',');
5933
+ }
5812
5934
 
5813
- // inspector and label editing aren't fully synced - stop editing if inspector opens
5814
- // gui.on('inspector_on', function() {
5815
- // stopEditing();
5816
- // });
5935
+ hit.on('change', function(e) {
5936
+ if (e.mode != 'selection') return;
5937
+ var ids = hit.getSelectionIds();
5938
+ if (ids.length > 0) {
5939
+ // enter this mode when we're ready to show the selection options
5940
+ // (this closes any other active mode, e.g. box_tool)
5941
+ gui.enterMode('selection_tool');
5942
+ popup.show();
5943
+ } else {
5944
+ popup.hide();
5945
+ }
5946
+ });
5817
5947
 
5818
- gui.on('interaction_mode_change', function(e) {
5819
- if (e.mode != 'labels') {
5820
- stopDragging();
5821
- }
5822
- });
5948
+ new SimpleButton(popup.findChild('.delete-btn')).on('click', function() {
5949
+ var cmd = '-filter invert ids=' + getIdsOpt();
5950
+ runCommand(cmd);
5951
+ });
5823
5952
 
5824
- // down event on svg
5825
- // a: off text
5826
- // -> stop editing
5827
- // b: on text
5828
- // 1: not editing -> nop
5829
- // 2: on selected text -> start dragging
5830
- // 3: on other text -> stop dragging, select new text
5953
+ new SimpleButton(popup.findChild('.filter-btn')).on('click', function() {
5954
+ var cmd = '-filter ids=' + getIdsOpt();
5955
+ runCommand(cmd);
5956
+ });
5831
5957
 
5832
- hit.on('dragstart', function(e) {
5833
- if (labelEditingEnabled()) {
5834
- onLabelDragStart(e);
5835
- } else if (locationEditingEnabled()) {
5836
- onLocationDragStart(e);
5837
- } else if (vertexEditingEnabled()) {
5838
- onVertexDragStart(e);
5839
- }
5840
- });
5958
+ new SimpleButton(popup.findChild('.split-btn')).on('click', function() {
5959
+ var cmd = '-split ids=' + getIdsOpt();
5960
+ runCommand(cmd);
5961
+ });
5841
5962
 
5842
- hit.on('drag', function(e) {
5843
- if (labelEditingEnabled()) {
5844
- onLabelDrag(e);
5845
- } else if (locationEditingEnabled()) {
5846
- onLocationDrag(e);
5847
- } else if (vertexEditingEnabled()) {
5848
- onVertexDrag(e);
5849
- }
5850
- });
5963
+ new SimpleButton(popup.findChild('.cancel-btn')).on('click', function() {
5964
+ hit.clearSelection();
5965
+ });
5851
5966
 
5852
- hit.on('dragend', function(e) {
5853
- if (locationEditingEnabled()) {
5854
- onLocationDragEnd(e);
5855
- stopDragging();
5856
- } else if (labelEditingEnabled()) {
5857
- stopDragging();
5858
- } else if (vertexEditingEnabled()) {
5859
- onVertexDragEnd(e);
5860
- stopDragging();
5861
- }
5967
+ function runCommand(cmd) {
5968
+ popup.hide();
5969
+ if (gui.console) gui.console.runMapshaperCommands(cmd, function(err) {
5970
+ reset();
5862
5971
  });
5972
+ }
5973
+ }
5863
5974
 
5864
- hit.on('click', function(e) {
5865
- if (labelEditingEnabled()) {
5866
- onLabelClick(e);
5867
- }
5868
- });
5975
+ // toNext, toPrev: trigger functions for switching between multiple records
5976
+ function Popup(gui, toNext, toPrev) {
5977
+ var self = new EventDispatcher();
5978
+ var parent = gui.container.findChild('.mshp-main-map');
5979
+ var el = El('div').addClass('popup').appendTo(parent).hide();
5980
+ var content = El('div').addClass('popup-content').appendTo(el);
5981
+ // multi-hit display and navigation
5982
+ var tab = El('div').addClass('popup-tab').appendTo(el).hide();
5983
+ var nav = El('div').addClass('popup-nav').appendTo(tab);
5984
+ var prevLink = El('span').addClass('popup-nav-arrow colored-text').appendTo(nav).text('◀');
5985
+ var navInfo = El('span').addClass('popup-nav-info').appendTo(nav);
5986
+ var nextLink = El('span').addClass('popup-nav-arrow colored-text').appendTo(nav).text('▶');
5987
+ var refresh = null;
5988
+ var currId = -1;
5869
5989
 
5870
- function onLocationDragStart(e) {
5871
- if (e.id >= 0) {
5872
- dragging = true;
5873
- triggerGlobalEvent('symbol_dragstart', e);
5874
- }
5875
- }
5990
+ el.addClass('rollover'); // used as a sentinel for the hover function
5876
5991
 
5877
- function onVertexDragStart(e) {
5878
- if (e.id >= 0) {
5879
- dragging = true;
5880
- }
5881
- }
5992
+ nextLink.on('click', toNext);
5993
+ prevLink.on('click', toPrev);
5994
+ gui.on('popup-needs-refresh', function() {
5995
+ if (refresh) refresh();
5996
+ });
5882
5997
 
5883
- function onLocationDrag(e) {
5884
- var lyr = hit.getHitTarget().layer;
5885
- var p = getPointCoordsById(e.id, hit.getHitTarget().layer);
5886
- if (!p) return;
5887
- var diff = translateDeltaDisplayCoords(e.dx, e.dy, ext);
5888
- p[0] += diff[0];
5889
- p[1] += diff[1];
5890
- self.dispatchEvent('location_change'); // signal map to redraw
5891
- triggerGlobalEvent('symbol_drag', e);
5998
+ self.show = function(id, ids, lyr, pinned) {
5999
+ var table = lyr.data; // table can be null (e.g. if layer has no attribute data)
6000
+ var editable = pinned && gui.interaction.getMode() == 'data';
6001
+ var maxHeight = parent.node().clientHeight - 36;
6002
+ currId = id;
6003
+ // stash a function for refreshing the current popup when data changes
6004
+ // while the popup is being displayed (e.g. while dragging a label)
6005
+ refresh = function() {
6006
+ var rec = table && (editable ? table.getRecordAt(id) : table.getReadOnlyRecordAt(id)) || {};
6007
+ render(content, rec, table, editable);
6008
+ };
6009
+ refresh();
6010
+ if (ids && ids.length > 1) {
6011
+ showNav(id, ids, pinned);
6012
+ } else {
6013
+ tab.hide();
5892
6014
  }
5893
-
5894
- function onVertexDrag(e) {
5895
- var target = hit.getHitTarget();
5896
- var p = ext.translatePixelCoords(e.x, e.y);
5897
- if (!activeVertexIds) {
5898
- activeVertexIds = internal.findNearestVertices(p, target.layer.shapes[e.id], target.arcs);
5899
- }
5900
- if (!activeVertexIds) return; // ignore error condition
5901
- if (gui.keyboard.shiftIsPressed()) {
5902
- internal.snapPointToArcEndpoint(p, activeVertexIds, target.arcs);
5903
- }
5904
- activeVertexIds.forEach(function(idx) {
5905
- internal.setVertexCoords(p[0], p[1], idx, target.arcs);
5906
- });
5907
- self.dispatchEvent('location_change'); // signal map to redraw
6015
+ el.show();
6016
+ if (content.node().clientHeight > maxHeight) {
6017
+ content.css('height:' + maxHeight + 'px');
5908
6018
  }
6019
+ };
5909
6020
 
5910
- function onLocationDragEnd(e) {
5911
- triggerGlobalEvent('symbol_dragend', e);
5912
- }
6021
+ self.hide = function() {
6022
+ if (!isOpen()) return;
6023
+ refresh = null;
6024
+ currId = -1;
6025
+ // make sure any pending edits are made before re-rendering popup
6026
+ GUI.blurActiveElement(); // this should be more selective -- could cause a glitch if typing in console
6027
+ content.empty();
6028
+ content.node().removeAttribute('style'); // remove inline height
6029
+ el.hide();
6030
+ };
5913
6031
 
5914
- function onVertexDragEnd(e) {
5915
- // kludge to get dataset to recalculate internal bounding boxes
5916
- hit.getHitTarget().arcs.transformPoints(function() {});
5917
- activeVertexIds = null;
5918
- }
6032
+ return self;
5919
6033
 
5920
- function onLabelClick(e) {
5921
- var textNode = getTextTarget3(e);
5922
- var rec = getLabelRecordById(e.id);
5923
- if (textNode && rec && isMultilineLabel(textNode)) {
5924
- toggleTextAlign(textNode, rec);
5925
- updateSymbol2(textNode, rec, e.id);
5926
- // e.stopPropagation(); // prevent pin/unpin on popup
5927
- }
5928
- }
6034
+ function isOpen() {
6035
+ return el.visible();
6036
+ }
5929
6037
 
5930
- function triggerGlobalEvent(type, e) {
5931
- if (e.id >= 0) {
5932
- // fire event to signal external editor that symbol coords have changed
5933
- gui.dispatchEvent(type, {FID: e.id, layer_name: hit.getHitTarget().layer.name});
5934
- }
5935
- }
6038
+ function showNav(id, ids, pinned) {
6039
+ var num = ids.indexOf(id) + 1;
6040
+ navInfo.text(' ' + num + ' / ' + ids.length + ' ');
6041
+ nextLink.css('display', pinned ? 'inline-block' : 'none');
6042
+ prevLink.css('display', pinned && ids.length > 2 ? 'inline-block' : 'none');
6043
+ tab.show();
6044
+ }
5936
6045
 
5937
- function getLabelRecordById(id) {
5938
- var table = hit.getTargetDataTable();
5939
- if (id >= 0 === false || !table) return null;
5940
- // add dx and dy properties, if not available
5941
- if (!table.fieldExists('dx')) {
5942
- table.addField('dx', 0);
5943
- }
5944
- if (!table.fieldExists('dy')) {
5945
- table.addField('dy', 0);
5946
- }
5947
- if (!table.fieldExists('text-anchor')) {
5948
- table.addField('text-anchor', '');
6046
+ function render(el, rec, table, editable) {
6047
+ var tableEl = El('table').addClass('selectable'),
6048
+ rows = 0;
6049
+ // self.hide(); // clean up if panel is already open
6050
+ el.empty(); // clean up if panel is already open
6051
+ utils.forEachProperty(rec, function(v, k) {
6052
+ var type;
6053
+ // missing GeoJSON fields are set to undefined on import; skip these
6054
+ if (v !== undefined) {
6055
+ type = getFieldType(v, k, table);
6056
+ renderRow(tableEl, rec, k, type, editable);
6057
+ rows++;
5949
6058
  }
5950
- return table.getRecordAt(id);
5951
- }
6059
+ });
5952
6060
 
5953
- function onLabelDragStart(e) {
5954
- var textNode = getTextTarget3(e);
5955
- var table = hit.getTargetDataTable();
5956
- if (!textNode || !table) return;
5957
- activeId = e.id;
5958
- activeRecord = getLabelRecordById(activeId);
5959
- dragging = true;
5960
- downEvt = e;
5961
- }
6061
+ if (rows > 0) {
6062
+ tableEl.appendTo(el);
5962
6063
 
5963
- function onLabelDrag(e) {
5964
- var scale = ext.getSymbolScale() || 1;
5965
- var textNode;
5966
- if (!dragging) return;
5967
- if (e.id != activeId) {
5968
- error("Mismatched hit ids:", e.id, activeId);
5969
- }
5970
- applyDelta(activeRecord, 'dx', e.dx / scale);
5971
- applyDelta(activeRecord, 'dy', e.dy / scale);
5972
- textNode = getTextTarget3(e);
5973
- if (!isMultilineLabel(textNode)) {
5974
- // update anchor position of single-line labels based on label position
5975
- // relative to anchor point, for better placement when eventual display font is
5976
- // different from mapshaper's font.
5977
- autoUpdateTextAnchor(textNode, activeRecord, getDisplayCoordsById(activeId, hit.getHitTarget().layer, ext));
5978
- }
5979
- // updateSymbol(targetTextNode, activeRecord);
5980
- updateSymbol2(textNode, activeRecord, activeId);
5981
- }
6064
+ tableEl.on('copy', function(e) {
6065
+ // remove leading or trailing tabs that sometimes get copied when
6066
+ // selecting from a table
6067
+ var pasted = window.getSelection().toString();
6068
+ var cleaned = pasted.replace(/^\t/, '').replace(/\t$/, '');
6069
+ if (pasted != cleaned && !window.clipboardData) { // ignore ie
6070
+ (e.clipboardData || e.originalEvent.clipboardData).setData("text", cleaned);
6071
+ e.preventDefault(); // don't copy original string with tabs
6072
+ }
6073
+ });
5982
6074
 
5983
- function getSymbolNodeById(id, parent) {
5984
- // TODO: optimize selector
5985
- var sel = '[data-id="' + id + '"]';
5986
- return parent.querySelector(sel);
6075
+ } else {
6076
+ // Some individual features can have undefined values for some or all of
6077
+ // their data properties (properties are set to undefined when an input JSON file
6078
+ // has inconsistent fields, or after force-merging layers with inconsistent fields).
6079
+ el.html(utils.format('<div class="note">This %s is missing attribute data.</div>',
6080
+ table && table.getFields().length > 0 ? 'feature': 'layer'));
5987
6081
  }
6082
+ }
5988
6083
 
5989
- function getTextTarget3(e) {
5990
- if (e.id > -1 === false || !e.container) return null;
5991
- return getSymbolNodeById(e.id, e.container);
6084
+ function renderRow(table, rec, key, type, editable) {
6085
+ var rowHtml = '<td class="field-name">%s</td><td><span class="value">%s</span> </td>';
6086
+ var val = rec[key];
6087
+ var str = formatInspectorValue(val, type);
6088
+ var cell = El('tr')
6089
+ .appendTo(table)
6090
+ .html(utils.format(rowHtml, key, utils.htmlEscape(str)))
6091
+ .findChild('.value');
6092
+ setFieldClass(cell, val, type);
6093
+ if (editable) {
6094
+ editItem(cell, rec, key, type);
5992
6095
  }
6096
+ }
5993
6097
 
5994
- function getTextTarget2(e) {
5995
- var el = e && e.targetSymbol || null;
5996
- if (el && el.tagName == 'tspan') {
5997
- el = el.parentNode;
5998
- }
5999
- return el && el.tagName == 'text' ? el : null;
6000
- }
6098
+ function setFieldClass(el, val, type) {
6099
+ var isNum = type ? type == 'number' : utils.isNumber(val);
6100
+ var isNully = val === undefined || val === null || val !== val;
6101
+ var isEmpty = val === '';
6102
+ el.classed('num-field', isNum);
6103
+ el.classed('object-field', type == 'object');
6104
+ el.classed('null-value', isNully);
6105
+ el.classed('empty', isEmpty);
6106
+ }
6001
6107
 
6002
- function getTextTarget(e) {
6003
- var el = e.target;
6004
- if (el.tagName == 'tspan') {
6005
- el = el.parentNode;
6108
+ function editItem(el, rec, key, type) {
6109
+ var input = new ClickText2(el),
6110
+ strval = formatInspectorValue(rec[key], type),
6111
+ parser = getInputParser(type);
6112
+ el.parent().addClass('editable-cell');
6113
+ el.addClass('colored-text dot-underline');
6114
+ input.on('change', function(e) {
6115
+ var val2 = parser(input.value()),
6116
+ strval2 = formatInspectorValue(val2, type);
6117
+ if (strval == strval2) {
6118
+ // contents unchanged
6119
+ } else if (val2 === null && type != 'object') { // allow null objects
6120
+ // invalid value; revert to previous value
6121
+ input.value(strval);
6122
+ } else {
6123
+ // field content has changed
6124
+ strval = strval2;
6125
+ gui.dispatchEvent('data_preupdate', {FID: currId}); // for undo/redo
6126
+ rec[key] = val2;
6127
+ gui.dispatchEvent('data_postupdate', {FID: currId});
6128
+ input.value(strval);
6129
+ setFieldClass(el, val2, type);
6130
+ self.dispatchEvent('update', {field: key, value: val2, id: currId});
6006
6131
  }
6007
- return el.tagName == 'text' ? el : null;
6008
- }
6009
-
6010
- // svg.addEventListener('mousedown', function(e) {
6011
- // var textTarget = getTextTarget(e);
6012
- // downEvt = e;
6013
- // if (!textTarget) {
6014
- // stopEditing();
6015
- // } else if (!editing) {
6016
- // // nop
6017
- // } else if (textTarget == targetTextNode) {
6018
- // startDragging();
6019
- // } else {
6020
- // startDragging();
6021
- // editTextNode(textTarget);
6022
- // }
6023
- // });
6024
-
6025
- // up event on svg
6026
- // a: currently dragging text
6027
- // -> stop dragging
6028
- // b: clicked on a text feature
6029
- // -> start editing it
6132
+ });
6133
+ }
6134
+ }
6030
6135
 
6136
+ function formatInspectorValue(val, type) {
6137
+ var str;
6138
+ if (type == 'date') {
6139
+ str = utils.formatDateISO(val);
6140
+ } else if (type == 'object') {
6141
+ str = val ? JSON.stringify(val) : "";
6142
+ } else {
6143
+ str = String(val);
6144
+ }
6145
+ return str;
6146
+ }
6031
6147
 
6032
- // svg.addEventListener('mouseup', function(e) {
6033
- // var textTarget = getTextTarget(e);
6034
- // var isClick = isClickEvent(e, downEvt);
6035
- // if (isClick && textTarget && textTarget == targetTextNode &&
6036
- // activeRecord && isMultilineLabel(targetTextNode)) {
6037
- // toggleTextAlign(targetTextNode, activeRecord);
6038
- // updateSymbol();
6039
- // }
6040
- // if (dragging) {
6041
- // stopDragging();
6042
- // } else if (isClick && textTarget) {
6043
- // editTextNode(textTarget);
6044
- // }
6045
- // });
6148
+ var inputParsers = {
6149
+ date: function(raw) {
6150
+ var d = new Date(raw);
6151
+ return isNaN(+d) ? null : d;
6152
+ },
6153
+ string: function(raw) {
6154
+ return raw;
6155
+ },
6156
+ number: function(raw) {
6157
+ var val = Number(raw);
6158
+ if (raw == 'NaN') {
6159
+ val = NaN;
6160
+ } else if (isNaN(val)) {
6161
+ val = null;
6162
+ }
6163
+ return val;
6164
+ },
6165
+ object: function(raw) {
6166
+ var val = null;
6167
+ try {
6168
+ val = JSON.parse(raw);
6169
+ } catch(e) {}
6170
+ return val;
6171
+ },
6172
+ boolean: function(raw) {
6173
+ var val = null;
6174
+ if (raw == 'true') {
6175
+ val = true;
6176
+ } else if (raw == 'false') {
6177
+ val = false;
6178
+ }
6179
+ return val;
6180
+ },
6181
+ multiple: function(raw) {
6182
+ var val = Number(raw);
6183
+ return isNaN(val) ? raw : val;
6184
+ }
6185
+ };
6046
6186
 
6047
- // block dbl-click navigation when editing
6048
- // mouse.on('dblclick', function(e) {
6049
- // if (editing) e.stopPropagation();
6050
- // }, null, eventPriority);
6187
+ function getInputParser(type) {
6188
+ return inputParsers[type || 'multiple'];
6189
+ }
6051
6190
 
6052
- // mouse.on('dragstart', function(e) {
6053
- // onLabelDrag(e);
6054
- // }, null, eventPriority);
6191
+ function getFieldType(val, key, table) {
6192
+ // if a field has a null value, look at entire column to identify type
6193
+ return internal.getValueType(val) || internal.getColumnType(key, table.getRecords());
6194
+ }
6055
6195
 
6056
- // mouse.on('drag', function(e) {
6057
- // var scale = ext.getSymbolScale() || 1;
6058
- // onLabelDrag(e);
6059
- // if (!dragging || !activeRecord) return;
6060
- // applyDelta(activeRecord, 'dx', e.dx / scale);
6061
- // applyDelta(activeRecord, 'dy', e.dy / scale);
6062
- // if (!isMultilineLabel(targetTextNode)) {
6063
- // // update anchor position of single-line labels based on label position
6064
- // // relative to anchor point, for better placement when eventual display font is
6065
- // // different from mapshaper's font.
6066
- // updateTextAnchor(targetTextNode, activeRecord);
6067
- // }
6068
- // // updateSymbol(targetTextNode, activeRecord);
6069
- // targetTextNode = updateSymbol2(targetTextNode, activeRecord, activeId);
6070
- // }, null, eventPriority);
6196
+ function InspectionControl2(gui, hit) {
6197
+ var _popup = new Popup(gui, hit.getSwitchTrigger(1), hit.getSwitchTrigger(-1));
6198
+ var _self = new EventDispatcher();
6071
6199
 
6072
- // mouse.on('dragend', function(e) {
6073
- // onLabelDrag(e);
6074
- // stopDragging();
6075
- // }, null, eventPriority);
6200
+ gui.on('interaction_mode_change', function(e) {
6201
+ if (e.mode == 'off') {
6202
+ inspect(-1); // clear the popup
6203
+ }
6204
+ });
6076
6205
 
6206
+ _popup.on('update', function(e) {
6207
+ _self.dispatchEvent('data_change', e.data); // let map know which field has changed
6208
+ });
6077
6209
 
6078
- // function onLabelDrag(e) {
6079
- // if (dragging) {
6080
- // e.stopPropagation();
6081
- // }
6082
- // }
6083
- }
6210
+ hit.on('change', function(e) {
6211
+ var ids;
6212
+ if (!inspecting()) return;
6213
+ ids = e.mode == 'selection' ? null : e.ids;
6214
+ inspect(e.id, e.pinned, ids);
6215
+ });
6084
6216
 
6085
- function stopDragging() {
6086
- dragging = false;
6087
- activeId = -1;
6088
- activeRecord = null;
6089
- // targetTextNode = null;
6090
- // svg.removeAttribute('class');
6217
+ // id: Id of a feature in the active layer, or -1
6218
+ function inspect(id, pin, ids) {
6219
+ var target = hit.getHitTarget();
6220
+ if (id > -1 && inspecting() && target && target.layer) {
6221
+ _popup.show(id, ids, target.layer, pin);
6222
+ } else {
6223
+ _popup.hide();
6224
+ }
6091
6225
  }
6092
6226
 
6093
- function isClickEvent(up, down) {
6094
- var elapsed = Math.abs(down.timeStamp - up.timeStamp);
6095
- var dx = up.screenX - down.screenX;
6096
- var dy = up.screenY - down.screenY;
6097
- var dist = Math.sqrt(dx * dx + dy * dy);
6098
- return dist <= 4 && elapsed < 300;
6227
+ // does the attribute inspector appear on rollover
6228
+ function inspecting() {
6229
+ return gui.interaction && gui.interaction.getMode() != 'off';
6099
6230
  }
6100
6231
 
6232
+ return _self;
6233
+ }
6101
6234
 
6102
- // function deselectText(el) {
6103
- // el.removeAttribute('class');
6104
- // }
6105
-
6106
- // function selectText(el) {
6107
- // el.setAttribute('class', 'selected');
6108
- // }
6235
+ // Test if map should be re-framed to show updated layer
6236
+ function mapNeedsReset(newBounds, prevBounds, viewportBounds, flags) {
6237
+ var viewportPct = getIntersectionPct(newBounds, viewportBounds);
6238
+ var contentPct = getIntersectionPct(viewportBounds, newBounds);
6239
+ var boundsChanged = !prevBounds.equals(newBounds);
6240
+ var inView = newBounds.intersects(viewportBounds);
6241
+ var areaChg = newBounds.area() / prevBounds.area();
6242
+ var chgThreshold = flags.proj ? 1e3 : 1e8;
6243
+ // don't reset if layer extent hasn't changed
6244
+ if (!boundsChanged) return false;
6245
+ // reset if layer is out-of-view
6246
+ if (!inView) return true;
6247
+ // reset if content is mostly offscreen
6248
+ if (viewportPct < 0.3 && contentPct < 0.9) return true;
6249
+ // reset if content bounds have changed a lot (e.g. after projection)
6250
+ if (areaChg > chgThreshold || areaChg < 1/chgThreshold) return true;
6251
+ return false;
6252
+ }
6109
6253
 
6254
+ // Returns proportion of bb2 occupied by bb1
6255
+ function getIntersectionPct(bb1, bb2) {
6256
+ return getBoundsIntersection(bb1, bb2).area() / bb2.area() || 0;
6257
+ }
6110
6258
 
6259
+ function getBoundsIntersection(a, b) {
6260
+ var c = new Bounds();
6261
+ if (a.intersects(b)) {
6262
+ c.setBounds(Math.max(a.xmin, b.xmin), Math.max(a.ymin, b.ymin),
6263
+ Math.min(a.xmax, b.xmax), Math.min(a.ymax, b.ymax));
6264
+ }
6265
+ return c;
6111
6266
  }
6112
6267
 
6113
6268
  var darkStroke = "#334",
@@ -6151,7 +6306,7 @@
6151
6306
  dotSize: 2.5
6152
6307
  }, polyline: {
6153
6308
  strokeColor: black,
6154
- strokeWidth: 2.5
6309
+ strokeWidth: 2.5,
6155
6310
  }
6156
6311
  },
6157
6312
  unselectedHoverStyles = {
@@ -6656,7 +6811,10 @@
6656
6811
  } else {
6657
6812
  arcs = getArcsForRendering(obj, ext);
6658
6813
  filter = getShapeFilter(arcs, ext);
6659
- canv.drawPathShapes(layer.shapes, arcs, style, filter);
6814
+ canv.drawStyledPaths(layer.shapes, arcs, style, filter);
6815
+ if (style.vertices) {
6816
+ canv.drawVertices(layer.shapes, arcs, style, filter);
6817
+ }
6660
6818
  }
6661
6819
  }
6662
6820
 
@@ -6730,7 +6888,7 @@
6730
6888
 
6731
6889
  /*
6732
6890
  // Original function, not optimized
6733
- _self.drawPathShapes = function(shapes, arcs, style) {
6891
+ _self.drawStyledPaths = function(shapes, arcs, style) {
6734
6892
  var startPath = getPathStart(_ext),
6735
6893
  drawPath = getShapePencil(arcs, _ext),
6736
6894
  styler = style.styler || null;
@@ -6743,8 +6901,28 @@
6743
6901
  };
6744
6902
  */
6745
6903
 
6904
+ _self.drawVertices = function(shapes, arcs, style, filter) {
6905
+ var iter = new internal.ShapeIter(arcs);
6906
+ var t = getScaledTransform(_ext);
6907
+ var radius = (style.strokeWidth * 0.8 || 2.2) * GUI.getPixelRatio() * getScaledLineScale(_ext);
6908
+ var color = style.strokeColor || 'black';
6909
+ var shp;
6910
+ _ctx.beginPath();
6911
+ _ctx.fillStyle = color;
6912
+ for (var i=0; i<shapes.length; i++) {
6913
+ shp = shapes[i];
6914
+ if (!shp || filter && !filter(shp)) continue;
6915
+ iter.init(shp);
6916
+ while (iter.hasNext()) {
6917
+ drawCircle(iter.x * t.mx + t.bx, iter.y * t.my + t.by, radius, _ctx);
6918
+ }
6919
+ }
6920
+ _ctx.fill();
6921
+ _ctx.closePath();
6922
+ };
6923
+
6746
6924
  // Optimized to draw paths in same-style batches (faster Canvas drawing)
6747
- _self.drawPathShapes = function(shapes, arcs, style, filter) {
6925
+ _self.drawStyledPaths = function(shapes, arcs, style, filter) {
6748
6926
  var styleIndex = {};
6749
6927
  var batchSize = 1500;
6750
6928
  var startPath = getPathStart(_ext, getScaledLineScale(_ext));
@@ -6872,7 +7050,7 @@
6872
7050
  }
6873
7051
  }
6874
7052
 
6875
- // TODO: consider using drawPathShapes(), which draws paths in batches
7053
+ // TODO: consider using drawStyledPaths(), which draws paths in batches
6876
7054
  // for faster Canvas rendering. Downside: changes stacking order, which
6877
7055
  // is bad if circles are graduated.
6878
7056
  _self.drawPoints = function(shapes, style) {
@@ -7812,6 +7990,10 @@
7812
7990
  _mouse.disable();
7813
7991
  });
7814
7992
 
7993
+ gui.on('undo_redo', function() {
7994
+ drawLayers();
7995
+ });
7996
+
7815
7997
  model.on('update', onUpdate);
7816
7998
 
7817
7999
  // Update display of segment intersections
@@ -7996,6 +8178,10 @@
7996
8178
  function updateOverlayLayer(e) {
7997
8179
  var style = getOverlayStyle(_activeLyr.layer, e);
7998
8180
  if (style) {
8181
+ // kludge to show vertices when editing path shapes
8182
+ if (gui.state.interaction_mode == 'vertices') {
8183
+ style.vertices = true;
8184
+ }
7999
8185
  _overlayLyr = utils.defaults({
8000
8186
  layer: filterLayerByIds(_activeLyr.layer, style.ids),
8001
8187
  style: style
@@ -8235,6 +8421,8 @@
8235
8421
  gui.map = new MshpMap(gui);
8236
8422
  gui.interaction = new InteractionMode(gui);
8237
8423
  gui.session = new SessionHistory(gui);
8424
+ gui.undo = new Undo(gui);
8425
+ gui.state = {};
8238
8426
 
8239
8427
  gui.showProgressMessage = function(msg) {
8240
8428
  if (!gui.progressMessage) {