diff --git a/zeppelin-distribution/src/bin_license/LICENSE b/zeppelin-distribution/src/bin_license/LICENSE index ffecbe21ec0..b33ad64b108 100644 --- a/zeppelin-distribution/src/bin_license/LICENSE +++ b/zeppelin-distribution/src/bin_license/LICENSE @@ -114,6 +114,7 @@ The following components are provided under Apache License. (Apache 2.0) Utility classes for Jetty (org.mortbay.jetty:jetty-util:6.1.26 - http://javadox.com/org.mortbay.jetty/jetty/6.1.26/overview-tree.html) (Apache 2.0) Servlet API (org.mortbay.jetty:servlet-api:2.5-20081211 - https://en.wikipedia.org/wiki/Jetty_(web_server)) (Apache 2.0) Google HTTP Client Library for Java (com.google.http-client:google-http-client-jackson2:1.21.0 - https://github.com/google/google-http-java-client/tree/dev/google-http-client-jackson2) + (Apache 2.0) angular-esri-map (https://github.com/Esri/angular-esri-map) ======================================================================== MIT licenses diff --git a/zeppelin-web/bower.json b/zeppelin-web/bower.json index ae29f70cca9..e20493fa7fc 100644 --- a/zeppelin-web/bower.json +++ b/zeppelin-web/bower.json @@ -32,7 +32,8 @@ "bootstrap3-dialog": "bootstrap-dialog#~1.34.7", "handsontable": "~0.24.2", "moment-duration-format": "^1.3.0", - "select2": "^4.0.3" + "select2": "^4.0.3", + "angular-esri-map": "~2.0.0" }, "devDependencies": { "angular-mocks": "1.5.0" diff --git a/zeppelin-web/src/app/app.js b/zeppelin-web/src/app/app.js index 20ccfb11f9e..f0f1d89cee5 100644 --- a/zeppelin-web/src/app/app.js +++ b/zeppelin-web/src/app/app.js @@ -33,7 +33,8 @@ 'xeditable', 'ngToast', 'focus-if', - 'ngResource' + 'ngResource', + 'esri.map' ]) .filter('breakFilter', function() { return function(text) { diff --git a/zeppelin-web/src/app/notebook/paragraph/paragraph-chart-selector.html b/zeppelin-web/src/app/notebook/paragraph/paragraph-chart-selector.html index 0c763189fa5..e2e4e352b10 100644 --- a/zeppelin-web/src/app/notebook/paragraph/paragraph-chart-selector.html +++ b/zeppelin-web/src/app/notebook/paragraph/paragraph-chart-selector.html @@ -47,6 +47,11 @@ ng-class="{'active': isGraphMode('scatterChart')}" ng-click="setGraphMode('scatterChart', true)"> + + + +
+ diff --git a/zeppelin-web/src/app/notebook/paragraph/paragraph-pivot.html b/zeppelin-web/src/app/notebook/paragraph/paragraph-pivot.html index 66f570b183f..9ddf9820640 100644 --- a/zeppelin-web/src/app/notebook/paragraph/paragraph-pivot.html +++ b/zeppelin-web/src/app/notebook/paragraph/paragraph-pivot.html @@ -32,7 +32,7 @@ -
+
Keys @@ -165,4 +165,52 @@
+ +
+
+ + Latitude +
    +
  • +
    + {{paragraph.config.graph.map.lat.name}} +
    +
  • +
+
+
+
+ + Longitude +
    +
  • +
    + {{paragraph.config.graph.map.lng.name}} +
    +
  • +
+
+
+
+ + Pin contents +
    +
  • +
    + {{col.name}} +
    +
  • +
+
+
+
diff --git a/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js b/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js index f1d04cd8f37..2dd25720c9a 100644 --- a/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js +++ b/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js @@ -16,7 +16,7 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $rootScope, $route, $window, $routeParams, $location, $timeout, $compile, $http, websocketMsgSrv, baseUrlSrv, ngToast, - saveAsService) { + saveAsService, esriLoader) { var ANGULAR_FUNCTION_OBJECT_NAME_PREFIX = '_Z_ANGULAR_FUNC_'; $scope.parentNote = null; $scope.paragraph = null; @@ -93,6 +93,7 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r $scope.parentNote = note; $scope.originalText = angular.copy(newParagraph.text); $scope.chart = {}; + $scope.baseMapOption = ['Streets', 'Satellite', 'Hybrid', 'Topo', 'Gray', 'Oceans', 'Terrain']; $scope.colWidthOption = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; $scope.paragraphFocused = false; if (newParagraph.focus) { @@ -245,6 +246,22 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r config.graph.scatter = {}; } + if (!config.graph.map) { + config.graph.map = {}; + } + + if (!config.graph.map.baseMapType) { + config.graph.map.baseMapType = $scope.baseMapOption[0]; + } + + if (!config.graph.map.isOnline) { + config.graph.map.isOnline = true; + } + + if (!config.graph.map.pinCols) { + config.graph.map.pinCols = []; + } + if (config.enabled === undefined) { config.enabled = true; } @@ -907,6 +924,8 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r if (!type || type === 'table') { setTable($scope.paragraph.result, refresh); + } else if (type === 'map') { + setMap($scope.paragraph.result, refresh); } else { setD3Chart(type, $scope.paragraph.result, refresh); } @@ -1131,6 +1150,236 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r $timeout(retryRenderer); }; + var setMap = function(data, refresh) { + var createPinMapLayer = function(pins, cb) { + esriLoader.require(['esri/layers/FeatureLayer'], function(FeatureLayer) { + var pinLayer = new FeatureLayer({ + id: 'pins', + spatialReference: $scope.map.spatialReference, + geometryType: 'point', + source: pins, + fields: [], + objectIdField: '_ObjectID', + renderer: $scope.map.pinRenderer, + popupTemplate: { + title: '[{_lng}, {_lat}]', + content: [{ + type: 'fields', + fieldInfos: [] + }] + } + }); + + // add user-selected pin info fields to popup + var pinInfoCols = $scope.paragraph.config.graph.map.pinCols; + for (var i = 0; i < pinInfoCols.length; ++i) { + pinLayer.popupTemplate.content[0].fieldInfos.push({ + fieldName: pinInfoCols[i].name, + visible: true + }); + } + cb(pinLayer); + }); + }; + + var getMapPins = function(cb) { + esriLoader.require(['esri/geometry/Point'], function(Point, FeatureLayer) { + var latCol = $scope.paragraph.config.graph.map.lat; + var lngCol = $scope.paragraph.config.graph.map.lng; + var pinInfoCols = $scope.paragraph.config.graph.map.pinCols; + var pins = []; + + // construct objects for pins + if (latCol && lngCol && data.rows) { + for (var i = 0; i < data.rows.length; ++i) { + var row = data.rows[i]; + var lng = row[lngCol.index]; + var lat = row[latCol.index]; + var pin = { + geometry: new Point({ + longitude: lng, + latitude: lat, + spatialReference: $scope.map.spatialReference + }), + attributes: { + _ObjectID: i, + _lng: lng, + _lat: lat + } + }; + + // add pin info from user-selected columns + for (var j = 0; j < pinInfoCols.length; ++j) { + var col = pinInfoCols[j]; + pin.attributes[col.name] = row[col.index]; + } + pins.push(pin); + } + } + cb(pins); + }); + }; + + var updateMapPins = function() { + var pinLayer = $scope.map.map.findLayerById('pins'); + $scope.map.popup.close(); + if (pinLayer) { + $scope.map.map.remove(pinLayer); + } + + // add pins to map as layer + getMapPins(function(pins) { + createPinMapLayer(pins, function(pinLayer) { + $scope.map.map.add(pinLayer); + if (pinLayer.source.length > 0) { + $scope.map.goTo(pinLayer.source); + } + }); + }); + }; + + var createMap = function(mapdiv) { + // prevent zooming with the scroll wheel + var disableZoom = function(e) { + var evt = e || window.event; + evt.cancelBubble = true; + evt.returnValue = false; + if (evt.stopPropagation) { + evt.stopPropagation(); + } + }; + var eName = window.WheelEvent ? 'wheel' : // Modern browsers + window.MouseWheelEvent ? 'mousewheel' : // WebKit and IE + 'DOMMouseScroll'; // Old Firefox + mapdiv.addEventListener(eName, disableZoom, true); + + esriLoader.require(['esri/views/MapView', + 'esri/Map', + 'esri/renderers/SimpleRenderer', + 'esri/symbols/SimpleMarkerSymbol'], + function(MapView, Map, SimpleRenderer, SimpleMarkerSymbol) { + $scope.map = new MapView({ + container: mapdiv, + map: new Map({ + basemap: $scope.paragraph.config.graph.map.baseMapType.toLowerCase() + }), + center: [-106.3468, 56.1304], // Canada (lng, lat) + zoom: 2, + pinRenderer: new SimpleRenderer({ + symbol: new SimpleMarkerSymbol({ + 'color': [255, 0, 0, 0.5], + 'size': 16.5, + 'outline': { + 'color': [0, 0, 0, 1], + 'width': 1.125, + }, + // map pin SVG path + 'path': 'M16,3.5c-4.142,0-7.5,3.358-7.5,7.5c0,4.143,7.5,18.121,7.5,' + + '18.121S23.5,15.143,23.5,11C23.5,6.858,20.143,3.5,16,3.5z ' + + 'M16,14.584c-1.979,0-3.584-1.604-3.584-3.584S14.021,7.416,' + + '16,7.416S19.584,9.021,19.584,11S17.979,14.584,16,14.584z' + }) + }) + }); + + $scope.map.on('click', function() { + // ArcGIS JS API 4.0 does not account for scrolling or position + // changes by default (this is a bug, to be fixed in the upcoming + // version 4.1; see https://geonet.esri.com/thread/177238#comment-609681). + // This results in a misaligned popup. + + // Workaround: manually set popup position to match position of selected pin + if ($scope.map.popup.selectedFeature) { + $scope.map.popup.location = $scope.map.popup.selectedFeature.geometry; + } + }); + $scope.map.then(updateMapPins); + }); + }; + + var checkMapOnline = function(cb) { + // are we able to get a response from the ArcGIS servers? + var callback = function(res) { + var online = (res.status > 0); + $scope.paragraph.config.graph.map.isOnline = online; + cb(online); + }; + $http.head('//services.arcgisonline.com/arcgis/', { + timeout: 5000, + withCredentials: false + }).then(callback, callback); + }; + + var renderMap = function() { + var mapdiv = angular.element('#p' + $scope.paragraph.id + '_map') + .css('height', $scope.paragraph.config.graph.height) + .children('div').get(0); + + // on chart type change, destroy map to force reinitialization. + if ($scope.map && !refresh) { + $scope.map.map.destroy(); + $scope.map.pinRenderer = null; + $scope.map = null; + } + + var requireMapCSS = function() { + var url = '//js.arcgis.com/4.0/esri/css/main.css'; + if (!angular.element('link[href="' + url + '"]').length) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = url; + angular.element('head').append(link); + } + }; + + var requireMapJS = function(cb) { + if (!esriLoader.isLoaded()) { + esriLoader.bootstrap({ + url: '//js.arcgis.com/4.0' + }).then(cb); + } else { + cb(); + } + }; + + checkMapOnline(function(online) { + // we need an internet connection to use the map + if (online) { + // create map if not exists. + if (!$scope.map) { + requireMapCSS(); + requireMapJS(function() { + createMap(mapdiv); + }); + } else { + updateMapPins(); + } + } + }); + }; + + var retryRenderer = function() { + if (angular.element('#p' + $scope.paragraph.id + '_map div').length) { + try { + renderMap(); + } catch (err) { + console.log('Map drawing error %o', err); + } + } else { + $timeout(retryRenderer,10); + } + }; + $timeout(retryRenderer); + }; + + $scope.setMapBaseMap = function(bm) { + $scope.paragraph.config.graph.map.baseMapType = bm; + if ($scope.map) { + $scope.map.map.basemap = bm.toLowerCase(); + } + }; + $scope.isGraphMode = function(graphName) { var activeAppId = _.get($scope.paragraph.config, 'helium.activeApp'); if ($scope.getResultType() === 'TABLE' && $scope.getGraphMode() === graphName && !activeAppId) { @@ -1193,6 +1442,24 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r $scope.setGraphMode($scope.paragraph.config.graph.mode, true, false); }; + $scope.removeMapOptionLat = function(idx) { + $scope.paragraph.config.graph.map.lat = null; + clearUnknownColsFromGraphOption(); + $scope.setGraphMode($scope.paragraph.config.graph.mode, true, false); + }; + + $scope.removeMapOptionLng = function(idx) { + $scope.paragraph.config.graph.map.lng = null; + clearUnknownColsFromGraphOption(); + $scope.setGraphMode($scope.paragraph.config.graph.mode, true, false); + }; + + $scope.removeMapOptionPinInfo = function(idx) { + $scope.paragraph.config.graph.map.pinCols.splice(idx, 1); + clearUnknownColsFromGraphOption(); + $scope.setGraphMode($scope.paragraph.config.graph.mode, true, false); + }; + /* Clear unknown columns from graph option */ var clearUnknownColsFromGraphOption = function() { var unique = function(list) { @@ -1223,7 +1490,7 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r } }; - var removeUnknownFromScatterSetting = function(fields) { + var removeUnknownFromFields = function(fields) { for (var f in fields) { if (fields[f]) { var found = false; @@ -1235,7 +1502,7 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r break; } } - if (!found) { + if (!found && (fields[f] instanceof Object) && !(fields[f] instanceof Array)) { fields[f] = null; } } @@ -1250,7 +1517,11 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r unique($scope.paragraph.config.graph.groups); removeUnknown($scope.paragraph.config.graph.groups); - removeUnknownFromScatterSetting($scope.paragraph.config.graph.scatter); + removeUnknownFromFields($scope.paragraph.config.graph.scatter); + + unique($scope.paragraph.config.graph.map.pinCols); + removeUnknown($scope.paragraph.config.graph.map.pinCols); + removeUnknownFromFields($scope.paragraph.config.graph.map); }; /* select default key and value if there're none selected */ @@ -1271,6 +1542,23 @@ angular.module('zeppelinWebApp').controller('ParagraphCtrl', function($scope, $r $scope.paragraph.config.graph.scatter.xAxis = $scope.paragraph.result.columnNames[0]; } } + + /* try to find columns for the map logitude and latitude */ + var findDefaultMapCol = function(settingName, keyword) { + var col; + if (!$scope.paragraph.config.graph.map[settingName]) { + for (var i = 0; i < $scope.paragraph.result.columnNames.length; ++i) { + col = $scope.paragraph.result.columnNames[i]; + if (col.name.toUpperCase().indexOf(keyword) !== -1) { + $scope.paragraph.config.graph.map[settingName] = col; + break; + } + } + } + }; + + findDefaultMapCol('lat', 'LAT'); + findDefaultMapCol('lng', 'LONG'); }; var pivot = function(data) { diff --git a/zeppelin-web/src/app/notebook/paragraph/paragraph.css b/zeppelin-web/src/app/notebook/paragraph/paragraph.css index d8b464eeed1..cb42389c592 100644 --- a/zeppelin-web/src/app/notebook/paragraph/paragraph.css +++ b/zeppelin-web/src/app/notebook/paragraph/paragraph.css @@ -327,12 +327,41 @@ table.dataTable.table-condensed .sorting_desc:after { .tableDisplay div { } -.tableDisplay img { +.tableDisplay img:not(.esri-bitmap) { display: block; max-width: 100%; height: auto; } +.esri-display-object > svg { + overflow: visible; +} + +.esri-popup > .esri-docked.esri-dock-to-bottom { + padding: 8px; + margin-top: 0px; +} + +.esri-popup-main { + max-height: 100%; +} + +span.map-offline-text { + display: table; + width: 100%; + height: 100%; + text-align: center; +} + +span.map-offline-text > span { + display: table-cell; + vertical-align: middle; + font-size: 18px; + font-weight: 700; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + color: #212121; +} + .tableDisplay .btn-group span { margin: 10px 0 0 10px; font-size: 12px; @@ -350,7 +379,8 @@ table.dataTable.table-condensed .sorting_desc:after { } -.tableDisplay .option .columns { +.tableDisplay .option .columns, +div.esri-view { height: 100%; } diff --git a/zeppelin-web/src/index.html b/zeppelin-web/src/index.html index b23db7a7530..f195ecc94e8 100644 --- a/zeppelin-web/src/index.html +++ b/zeppelin-web/src/index.html @@ -146,6 +146,7 @@ + diff --git a/zeppelin-web/test/karma.conf.js b/zeppelin-web/test/karma.conf.js index f9f03a413fa..3705db07f2d 100644 --- a/zeppelin-web/test/karma.conf.js +++ b/zeppelin-web/test/karma.conf.js @@ -66,6 +66,7 @@ module.exports = function(config) { 'bower_components/moment-duration-format/lib/moment-duration-format.js', 'bower_components/select2/dist/js/select2.js', 'bower_components/angular-mocks/angular-mocks.js', + 'bower_components/angular-esri-map/dist/angular-esri-map.js' // endbower 'src/app/app.js', 'src/app/app.controller.js', diff --git a/zeppelin-web/test/spec/controllers/paragraph.js b/zeppelin-web/test/spec/controllers/paragraph.js index bb483f4b3c0..50691ba3591 100644 --- a/zeppelin-web/test/spec/controllers/paragraph.js +++ b/zeppelin-web/test/spec/controllers/paragraph.js @@ -39,7 +39,7 @@ describe('Controller: ParagraphCtrl', function() { 'getResultType', 'loadTableData', 'setGraphMode', 'isGraphMode', 'onGraphOptionChange', 'removeGraphOptionKeys', 'removeGraphOptionValues', 'removeGraphOptionGroups', 'setGraphOptionValueAggr', 'removeScatterOptionXaxis', 'removeScatterOptionYaxis', 'removeScatterOptionGroup', - 'removeScatterOptionSize']; + 'removeScatterOptionSize', 'removeMapOptionLat', 'removeMapOptionLng', 'removeMapOptionPinInfo']; functions.forEach(function(fn) { it('check for scope functions to be defined : ' + fn, function() {