diff --git a/package.json b/package.json index fa95fe6c071..746e4e80e4b 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "dependencies": { "debug": "^2.2.0", "electron-squirrel-startup": "^0.1.4", - "google-maps": "^3.1.0", "keytar": "^3.0.0", "localforage": "^1.3.0", "mongodb-collection-model": "^0.1.1", @@ -154,6 +153,7 @@ "uuid": "^2.0.1", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0", - "watchify": "^3.6.0" + "watchify": "^3.6.0", + "xor-it": "^1.0.1" } } diff --git a/src/app.js b/src/app.js index d23933059d1..32646dde58f 100644 --- a/src/app.js +++ b/src/app.js @@ -113,10 +113,6 @@ var Application = View.extend({ * @see http://learn.humanjavascript.com/react-ampersand/creating-a-router-and-pages */ router: 'object', - /** - * Enable/Disable features with one global switch - */ - features: 'object', clientStartedAt: 'date', clientStalledTimeout: 'number' }, @@ -237,7 +233,7 @@ var state = new Application({ var FEATURES = { querybuilder: true, keychain: true, - 'Geo Minicharts': true, + 'Google Map Minicharts': true, 'Connect with SSL': false, 'Connect with Kerberos': false, 'Connect with LDAP': false, diff --git a/src/connect/filereader-view.js b/src/connect/filereader-view.js index c5738efba61..31066f4c266 100644 --- a/src/connect/filereader-view.js +++ b/src/connect/filereader-view.js @@ -3,6 +3,7 @@ var _ = require('lodash'); var path = require('path'); var remote = window.require('remote'); var dialog = remote.require('dialog'); +var BrowserWindow = remote.require('browser-window'); var format = require('util').format; var bindings = require('ampersand-dom-bindings'); var fileReaderTemplate = require('./filereader-default.jade'); @@ -172,7 +173,7 @@ module.exports = InputView.extend({ this.runTests(); }, loadFileButtonClicked: function() { - dialog.showOpenDialog({ + dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { properties: ['openFile', 'multiSelections'] }, function(filenames) { this.inputValue = filenames || []; diff --git a/src/home/collection.jade b/src/home/collection.jade index fe4a41e672d..ee9f543d849 100644 --- a/src/home/collection.jade +++ b/src/home/collection.jade @@ -1,15 +1,4 @@ .collection-view.clearfix - div.modal.fade(tabindex='-1', role='dialog', arialabelledby='Share Schema Confirmation', data-hook='share-schema-confirmation') - div.modal-dialog.modal-sm - .modal-content - .modal-header - button.close(type='button', data-dismiss='modal', aria-label='Close') - span(aria-hidden='true') × - h4.modal-title Share Schema - .modal-body - p The schema definition in JSON format has been copied to the clipboard. - .modal-footer - button.btn.btn-default(type='button', data-dismiss='modal') Close header .row .col-md-6 diff --git a/src/home/collection.js b/src/home/collection.js index 15f3c677ffc..5f7828ab9d9 100644 --- a/src/home/collection.js +++ b/src/home/collection.js @@ -6,12 +6,13 @@ var RefineBarView = require('../refine-view'); var MongoDBCollection = require('../models/mongodb-collection'); var SampledSchema = require('../models/sampled-schema'); var app = require('ampersand-app'); -var $ = require('jquery'); var _ = require('lodash'); +var remote = window.require('remote'); +var dialog = remote.require('dialog'); +var BrowserWindow = remote.require('browser-window'); +var format = require('util').format; var debug = require('debug')('scout:home:collection'); -require('bootstrap/js/modal'); - var MongoDBCollectionView = View.extend({ // modelType: 'Collection', template: require('./collection.jade'), @@ -83,7 +84,16 @@ var MongoDBCollectionView = View.extend({ onShareSchema: function() { var clipboard = window.require('clipboard'); clipboard.writeText(JSON.stringify(this.schema.serialize(), null, ' ')); - $(this.queryByHook('share-schema-confirmation')).modal('show'); + + var detail = format('The schema definition of %s has been copied to your ' + + 'clipboard in JSON format.', this.model._id); + + dialog.showMessageBox(BrowserWindow.getFocusedWindow(), { + type: 'info', + message: 'Share Schema', + detail: detail, + buttons: ['OK'] + }); }, onCollectionChanged: function() { var ns = this.parent.ns; diff --git a/src/minicharts/d3fns/coordinates.js b/src/minicharts/d3fns/coordinates.js new file mode 100644 index 00000000000..a8d39d35d15 --- /dev/null +++ b/src/minicharts/d3fns/coordinates.js @@ -0,0 +1,160 @@ +var d3 = require('d3'); +var _ = require('lodash'); +var shared = require('./shared'); +var tooltipHtml = require('./tooltip.jade'); + +require('../d3-tip')(d3); + +// var debug = require('debug')('scout:minicharts:coordinates'); + +var minicharts_d3fns_coordinates = function() { + // --- beginning chart setup --- + var width = 400; + var height = 100; + var options = { + view: null + }; + var margin = shared.margin; + margin.bottom = 20; + + var xScale = d3.scale.linear(); + var yScale = d3.scale.linear(); + + var xAxis = d3.svg.axis() + .ticks(10) + .scale(xScale) + .orient('bottom'); + + var yAxis = d3.svg.axis() + .ticks(6) + .scale(yScale) + .orient('left'); + + var coordFormat = d3.format('.1f'); + + // set up tooltips + var tip = d3.tip() + .attr('class', 'd3-tip') + .direction('n') + .offset([-9, 0]); + + // --- end chart setup --- + + function chart(selection) { + selection.each(function(data) { + var el = d3.select(this); + var innerWidth = width - margin.left - margin.right; + var innerHeight = height - margin.top - margin.bottom; + + // setup tool tips + tip.html(function(d) { + return tooltipHtml({ + label: 'lng: ' + coordFormat(d[0]) + ', lat: ' + coordFormat(d[1]) + }); + }); + el.call(tip); + + xScale + .domain([ + d3.min(data, function(d) { return d[0]; }) - 3, + d3.max(data, function(d) { return d[0]; }) + 3 + ]) + .range([0, innerWidth]); + + yScale + .domain([ + d3.min(data, function(d) { return d[1]; }) - 3, + d3.max(data, function(d) { return d[1]; }) + 3 + ]) + .range([innerHeight, 0]); + + var g = el.selectAll('g').data([null]); + + // append g element if it doesn't exist yet + g.enter() + .append('g') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') + .attr('width', innerWidth) + .attr('height', innerHeight); + + var x = g.selectAll('.x.axis').data([null]); + x.enter().append('g') + .attr('class', 'x axis') + .attr('transform', 'translate(0, ' + innerHeight + ')') + .append('text') + // .attr('class', 'label') + .attr('x', innerWidth) + .attr('y', -6) + .style('text-anchor', 'end') + .text('lng'); + x.call(xAxis); + + var y = g.selectAll('.y.axis').data([null]); + y.enter().append('g') + .attr('class', 'y axis') + .append('text') + // .attr('class', 'label') + .attr('transform', 'rotate(-90)') + .attr('y', 6) + .attr('dy', '.71em') + .style('text-anchor', 'end') + .text('lat'); + y.call(yAxis); + + // select all g.bar elements + var circle = g.selectAll('circle.circle') + .data(data); + + circle + .transition() // only apply transition to already existing elements + .attr('cx', function(d) { + return xScale(d[0]); + }) + .attr('cy', function(d) { + return yScale(d[1]); + }); + + circle.enter().append('circle') + .attr('class', 'circle') + .attr('cx', function(d) { + return xScale(d[0]); + }) + .attr('cy', function(d) { + return yScale(d[1]); + }) + .attr('r', 4.5) + .on('mouseover', tip.show) + .on('mouseout', tip.hide); + + circle.exit().remove(); + }); + } + + chart.width = function(value) { + if (!arguments.length) { + return width; + } + width = value; + return chart; + }; + + chart.height = function(value) { + if (!arguments.length) { + return height; + } + height = value; + return chart; + }; + + chart.options = function(value) { + if (!arguments.length) { + return options; + } + _.assign(options, value); + return chart; + }; + + return chart; +}; + +module.exports = minicharts_d3fns_coordinates; diff --git a/src/minicharts/d3fns/geo.js b/src/minicharts/d3fns/geo.js index 47fe7b23054..108fddbdd5b 100644 --- a/src/minicharts/d3fns/geo.js +++ b/src/minicharts/d3fns/geo.js @@ -1,67 +1,47 @@ var d3 = require('d3'); var _ = require('lodash'); -// var shared = require('./shared'); +var shared = require('./shared'); var debug = require('debug')('scout:minicharts:geo'); -var GoogleMapsLoader = require('google-maps'); var mapStyle = require('./mapstyle'); -var async = require('async'); +// var async = require('async'); var app = require('ampersand-app'); +var format = require('util').format; +var remote = window.require('remote'); +var dialog = remote.require('dialog'); +var BrowserWindow = remote.require('browser-window'); var SHIFTKEY = 16; - - -/* eslint wrap-iife:0 */ -var Singleton = (function() { - var instance; - - function createInstance() { - var object = {}; - return object; - } - - return { - getInstance: function() { - if (!instance) { - instance = createInstance(); - } - return instance; - } - }; -})(); - +var APIKEY = 'AIzaSyDrhE1qbcnNIh4sK3t7GEcbLRdCNKWjlt0'; + +var FATAL_GOOGLE_ERROR_CODES = [ + 'InvalidKeyOrUnauthorizedURLMapError', + 'NotLoadingAPIFromGoogleMapError', + 'TOSViolationMapError', + 'UnauthorizedURLForClientIdMapError' +]; // From: http://davidbcalhoun.com/2014/async.parallel-with-a-simple-timeout-node-js/ // async.parallel with optional timeout (options.timeoutMS) -function parallel(options, tasks, cb) { - // sanity checks - options = options || {}; - - // no timeout wrapper; passthrough to async.parallel - if (typeof options.timeoutMS !== 'number') return async.parallel(tasks, cb); - - var timeout = setTimeout(function() { - // remove timeout, so we'll know we already erred out - timeout = null; - - // error out - cb('async.parallel timed out out after ' + options.timeoutMS + 'ms.', null); - }, options.timeoutMS); - - async.parallel(tasks, function(err, result) { - // after all tasks are complete - - // noop if timeout was called and annulled - if (!timeout) return; - - // cancel timeout (if timeout was set longer, and all parallel tasks finished sooner) - clearTimeout(timeout); - - // passthrough the data to the cb - cb(err, result); - }); -} - - -var singleton = Singleton.getInstance(); +// function parallel(options, tasks, cb) { +// // sanity checks +// options = options || {}; +// // no timeout wrapper; passthrough to async.parallel +// if (typeof options.timeoutMS !== 'number') return async.parallel(tasks, cb); +// var timeout = setTimeout(function() { +// // remove timeout, so we'll know we already erred out +// timeout = null; +// // error out +// cb('Loading Google Maps timed out out after ' + options.timeoutMS + 'ms.', null); +// }, options.timeoutMS); +// async.parallel(tasks, function(err, result) { +// // after all tasks are complete +// // noop if timeout was called and annulled +// if (!timeout) return; +// // cancel timeout (if timeout was set longer, and all parallel tasks finished sooner) +// clearTimeout(timeout); +// // passthrough the data to the cb +// cb(err, result); +// }); +// } var minicharts_d3fns_geo = function() { // --- beginning chart setup --- @@ -78,15 +58,83 @@ var minicharts_d3fns_geo = function() { view: null }; - // var margin = shared.margin; + var margin = shared.margin; + + function disableMapsFeature(permanent) { + // disable both in feature flag (for this run) and localStorage + app.setFeature('Google Map Minicharts', false); + if (permanent) { + localStorage.disableGoogleMaps = true; + } + delete window.google; + options.view.parent.render(); + } + + function loadGoogleMaps(done) { + var originalAlert = window.alert; + + window.alert = function(text) { + window.alert = originalAlert; + var match = text.match(/Error Code: (.*)$/); + var errorCode = null; + if (match) { + errorCode = match[1].trim(); + } + var message; + var detail; + if (FATAL_GOOGLE_ERROR_CODES.indexOf(errorCode) !== -1) { + // maps API key is not valid, we may have had to deactivate it + message = 'The Google Maps API key used in Compass is no longer ' + + 'valid.'; + detail = 'Compass will disable the Google Map feature permanently and ' + + 'replace the map with a simplified coordinate chart. Please check ' + + 'for an update to Compass to re-enable this feature.'; + disableMapsFeature(true); + debug('Error with code "%s" while loading Google Maps. Disabling Geo ' + + 'Querybuilder feature permanently.', errorCode); + } else { + message = 'There was a problem loading the Google Map.'; + detail = 'Compass will disable the Google Map feature temporarily ' + + 'and replace the map with a simplified coordinate chart. Compass ' + + 'will try to load a Google Map again next time you use the ' + + 'application.'; + disableMapsFeature(false); + debug('Error with code "%s" while loading Google Maps. Disabling Geo ' + + 'Querybuilder feature temporarily.', errorCode); + } + // show error dialog + dialog.showMessageBox(BrowserWindow.getFocusedWindow(), { + type: 'error', + title: 'Error loading Google Maps', + message: message, + detail: detail, + buttons: ['OK'] + }); + + // @todo thomasr/imlucas: add call to metrics.error here with errror code + }; + + var script = document.createElement('script'); + script.setAttribute('type', 'text/javascript'); + script.src = format('https://maps.googleapis.com/maps/api/js?key=%s&libraries=geometry', + APIKEY); + script.onerror = function() { + done('Error ocurred while loading Google Maps.'); + }; + script.onload = function() { + done(null, window.google); + }; + document.getElementsByTagName('head')[0].appendChild(script); + } + function pointInCircle(point, radius, center) { - return singleton.google.maps.geometry.spherical.computeDistanceBetween(point, center) <= radius; + return window.google.maps.geometry.spherical.computeDistanceBetween(point, center) <= radius; } function selectPoints() { var frame = options.el; - var google = singleton.google; + var google = window.google; if (selectionCircle.getRadius() === 0) { d3.select(frame).selectAll('.marker circle') @@ -120,7 +168,7 @@ var minicharts_d3fns_geo = function() { return; } - var google = singleton.google; + var google = window.google; var frame = this; var center = d3.mouse(frame); @@ -195,33 +243,51 @@ var minicharts_d3fns_geo = function() { selection.each(function(data) { var el = d3.select(this); - if (!singleton.google) { - parallel({ timeoutMS: 5000 }, [ // 5 second timeout - // tasks - function(done) { - GoogleMapsLoader.KEY = 'AIzaSyDrhE1qbcnNIh4sK3t7GEcbLRdCNKWjlt0'; - GoogleMapsLoader.LIBRARIES = ['geometry']; - GoogleMapsLoader.load(function(g) { - done(null, g); - }); - } - ], - // calback - function(err, results) { + var innerDiv = el.selectAll('div.map').data([null]); + innerDiv.enter().append('div') + .attr('class', 'map') + .style({ + width: (width - margin.left - margin.right) + 'px', + height: (height - margin.top - margin.bottom) + 'px', + padding: margin.top + 'px ' + margin.right + 'px ' + margin.bottom + + 'px ' + margin.left + 'px;' + }); + + if (!window.google) { + loadGoogleMaps(function(err) { if (err) { - debug('Error: Google map could not be loaded, disabling feature', el); - // google map load timed out, disable geo feature for runtime remainder and reload - app.setFeature('Geo Minicharts', false); - options.view.parent.render(); - return; + disableMapsFeature(); + } else { + chart.call(this, selection); } - singleton.google = results[0]; - chart.call(this, selection); }); return; } - - var google = singleton.google; + // parallel({ timeoutMS: 5000 }, [ // 5 second timeout + // // tasks + // function(done) { + // loadGoogleMaps(function(err, res) { + // if (err) { + // done(err); + // } + // done(null, res); + // }, options); + // } + // ], + // // callback + // function(err, results) { + // if (err) { + // disableMapsFeature(options); + // return; + // } + // debug('result', results[0]); + // window.google = results[0]; + // chart.call(this, selection); + // }); + // return; + // } + + var google = window.google; var bounds = new google.maps.LatLngBounds(); _.each(data, function(d) { @@ -231,7 +297,7 @@ var minicharts_d3fns_geo = function() { if (!googleMap) { // Create the Google Map - googleMap = new google.maps.Map(el.node(), { + googleMap = new google.maps.Map(innerDiv.node(), { disableDefaultUI: true, // disableDoubleClickZoom: true, scrollwheel: true, @@ -282,7 +348,7 @@ var minicharts_d3fns_geo = function() { }; // end overlay.draw overlay.setMap(googleMap); - el.on('mousedown', startSelection); + innerDiv.on('mousedown', startSelection); d3.select('body') .on('keydown', onKeyDown) @@ -337,7 +403,6 @@ var minicharts_d3fns_geo = function() { } }); } else { - debug('toplevel location field. fitting map.'); _.defer(function() { google.maps.event.trigger(googleMap, 'resize'); googleMap.fitBounds(bounds); @@ -378,7 +443,7 @@ var minicharts_d3fns_geo = function() { return; } selectionCircle.setVisible(true); - var c = new singleton.google.maps.LatLng(value[0][1], value[0][0]); + var c = new window.google.maps.LatLng(value[0][1], value[0][0]); selectionCircle.setCenter(c); selectionCircle.setRadius(value[1] * 1600); selectPoints(); diff --git a/src/minicharts/d3fns/index.js b/src/minicharts/d3fns/index.js index 84b2222f0e3..dc91b0292f4 100644 --- a/src/minicharts/d3fns/index.js +++ b/src/minicharts/d3fns/index.js @@ -4,5 +4,6 @@ module.exports = { date: require('./date'), string: require('./string'), objectid: require('./date'), - geo: require('./geo') + geo: require('./geo'), // google maps + coordinates: require('./coordinates') }; diff --git a/src/minicharts/index.js b/src/minicharts/index.js index 948b3de1b11..3d744cdc57e 100644 --- a/src/minicharts/index.js +++ b/src/minicharts/index.js @@ -54,17 +54,17 @@ module.exports = AmpersandView.extend(QueryBuilderMixin, { return idx % 2 === 1; }); if (_.min(lons) >= -180 && _.max(lons) <= 180 && _.min(lats) >= -90 && _.max(lats) <= 90) { - // attach the zipped up coordinates to the model where VizView would expect it return new ArrayCollection(_.zip(lons, lats)); } return false; }, /* eslint complexity: 0 */ - _geoCoordinateCheck: function() { + _geoCoordinateTransform: function() { var coords; - if (!app.isFeatureEnabled('Geo Minicharts')) return false; - if (!navigator.onLine) return false; - + if (this.model.name === 'Coordinates') { + // been here before, don't need to do it again + return true; + } if (this.model.name === 'Document') { if (this.model.fields.length !== 2 || !this.model.fields.get('type') @@ -100,11 +100,26 @@ module.exports = AmpersandView.extend(QueryBuilderMixin, { }, render: function() { this.renderWithTemplate(this); - if (this._geoCoordinateCheck()) { - this.viewOptions.renderMode = 'html'; - this.viewOptions.height = 250; - this.viewOptions.vizFn = vizFns.geo; - this.subview = new VizView(this.viewOptions); + this._geoCoordinateTransform(); + + if (this.model.name === 'Coordinates') { + // check if we can load google maps or if we need to fall back to + // a simpler coordinate chart + if (app.isFeatureEnabled('Google Map Minicharts') + && navigator.onLine + && !localStorage.disableGoogleMaps) { + this.viewOptions.renderMode = 'html'; + this.viewOptions.height = 250; + this.viewOptions.vizFn = vizFns.geo; + this.subview = new VizView(this.viewOptions); + } else { + // we have coordinates but cannot load google maps (offline, invalid + // key, etc.). Fall back to simplified coordinate chart. + this.viewOptions.renderMode = 'svg'; + this.viewOptions.height = 250; + this.viewOptions.vizFn = vizFns.coordinates; + this.subview = new VizView(this.viewOptions); + } } else if (['String', 'Number'].indexOf(this.model.name) !== -1 && this.model.unique === this.model.count) { // unique values get a div-based UniqueMinichart @@ -122,6 +137,7 @@ module.exports = AmpersandView.extend(QueryBuilderMixin, { // otherwise, create a svg-based VizView for d3 this.subview = new VizView(this.viewOptions); } + if (app.isFeatureEnabled('querybuilder')) { this.listenTo(this.subview, 'querybuilder', this.handleQueryBuilderEvent); } diff --git a/src/minicharts/index.less b/src/minicharts/index.less index 5de27788813..474d7724e45 100644 --- a/src/minicharts/index.less +++ b/src/minicharts/index.less @@ -220,9 +220,19 @@ svg.minichart { .axis path, .axis line { fill: none; - stroke: #000; + stroke: @gray7; shape-rendering: crispEdges; } + + .circle { + fill: @mc-fg; + stroke: @pw; + stroke-width: 1.5px; + + &.selected { + fill: @mc-fg-selected; + } + } } .tooltip-wrapper { diff --git a/src/minicharts/viz.js b/src/minicharts/viz.js index 4387c29acad..ed9a0536f59 100644 --- a/src/minicharts/viz.js +++ b/src/minicharts/viz.js @@ -10,7 +10,6 @@ var VizView = AmpersandView.extend({ _autoWidth: false, _autoHeight: false, props: { - data: 'any', className: 'any', vizFn: 'any', chart: 'any',