diff --git a/app/assets/scripts/actions/actions.js b/app/assets/scripts/actions/actions.js index 06bf6b27..d752b673 100644 --- a/app/assets/scripts/actions/actions.js +++ b/app/assets/scripts/actions/actions.js @@ -11,6 +11,23 @@ module.exports = Reflux.createActions({ 'resultsChange': {}, + // Filter actios + 'setDateFilter': { + shouldEmit: function (val) { + return [ 'all', 'week', 'month', 'year' ].indexOf(val) >= 0; + } + }, + 'setResolutionFilter': { + shouldEmit: function (val) { + return [ 'all', 'low', 'medium', 'high' ].indexOf(val) >= 0; + } + }, + 'setDataTypeFilter': { + shouldEmit: function (val) { + return [ 'all', 'service' ].indexOf(val) >= 0; + } + }, + // Results pane related actions. 'resultItemSelect': {}, 'resultItemView': {}, @@ -29,4 +46,7 @@ module.exports = Reflux.createActions({ 'goToLatest': {}, 'geocoderResult': {}, -}); \ No newline at end of file + + + 'miniMapClick': {}, +}); diff --git a/app/assets/scripts/components/app.js b/app/assets/scripts/components/app.js index d3a981fd..4907424c 100644 --- a/app/assets/scripts/components/app.js +++ b/app/assets/scripts/components/app.js @@ -4,13 +4,31 @@ var Router = require('react-router'); var RouteHandler = Router.RouteHandler; var InfoModal = require('./modals/info_modal'); var WelcomeModal = require('./modals/welcome_modal'); +var MessageModal = require('./modals/message_modal'); var Header = require('./header'); +var actions = require('../actions/actions'); var App = React.createClass({ + mixins: [ Router.State ], - aboutClickHandler: function(e) { - e.preventDefault(); - actions.openModal('about'); + componentDidMount: function () { + // Pull the search filter state from the URL. Why is this here instead + // of in the Filters component? Because we want to ensure that we set + // these filter parameters BEFORE the map component loads, since that is + // where the map move action will get fired, triggering the first API load. + // + // TODO: this is really a stopgap until we integrate the router more + // fully. (See routes.js for more.) + var params = this.getQuery(); + if (params.date) { + actions.setDateFilter(params.date); + } + if (params.resolution) { + actions.setResolutionFilter(params.resolution); + } + if (params.type) { + actions.setDataTypeFilter(params.type); + } }, render: function() { @@ -22,6 +40,7 @@ var App = React.createClass({ + ); } diff --git a/app/assets/scripts/components/filters.js b/app/assets/scripts/components/filters.js new file mode 100644 index 00000000..0644e41b --- /dev/null +++ b/app/assets/scripts/components/filters.js @@ -0,0 +1,97 @@ +'use strict'; +var React = require('react/addons'); +var Reflux = require('reflux'); +var Router = require('react-router'); +var Dropdown = require('./shared/dropdown'); +var actions = require('../actions/actions'); +var searchQuery = require('../stores/search_query_store'); + +var Filters = module.exports = React.createClass({ + mixins: [ + Reflux.listenTo(searchQuery, 'onSearchQuery'), + Router.Navigation, + Router.State + ], + + getInitialState: function () { + return { + date: 'all', + resolution: 'all', + dataType: 'all' + } + }, + + onSearchQuery: function (data) { + this.setState(data); + }, + + setDate: function (d) { + actions.setDateFilter(d.key); + this._updateUrl('date', d.key); + }, + + setResolution: function (d) { + actions.setResolutionFilter(d.key); + this._updateUrl('resolution', d.key) + }, + + setDataType: function (d) { + actions.setDataTypeFilter(d.key); + this._updateUrl('type', d.key); + }, + + _updateUrl: function (prop, value) { + var query = this.getQuery(); + if (value === 'all') { + delete query[prop] + } else { + query[prop] = value; + } + var routes = this.getRoutes(); + this.replaceWith(routes[routes.length - 1].name, this.getParams(), query); + }, + + + render: function() { + function filterItem (property, clickHandler, d) { + var klass = this.state[property] === d.key ? 'active' : ''; + var click = clickHandler.bind(this, d); + return ( +
+ {d.title} +
); + } + + var dates = [ + {key: 'all', title: 'All'}, + {key: 'week', title: 'Last week'}, + {key: 'month', title: 'Last month'}, + {key: 'year', title: 'Last year'} + ].map(filterItem.bind(this, 'date', this.setDate)); + + var resolutions = [ + {key: 'all', title: 'All'}, + {key: 'low', title: 'Low'}, + {key: 'medium', title: 'Medium'}, + {key: 'high', title: 'High'} + ].map(filterItem.bind(this, 'resolution', this.setResolution)); + + var dataTypes = [ + {key: 'all', title: 'All Images'}, + {key: 'service', title: 'Image + Map Layer'} + ].map(filterItem.bind(this, 'dataType', this.setDataType)); + + return ( + +
+
Time
+ {dates} +
Resolution
+ {resolutions} +
Data Type
+ {dataTypes} +
+
+ ); + } +}); diff --git a/app/assets/scripts/components/header.js b/app/assets/scripts/components/header.js index 42e7fb1f..99283155 100644 --- a/app/assets/scripts/components/header.js +++ b/app/assets/scripts/components/header.js @@ -1,9 +1,27 @@ 'use strict'; var React = require('react/addons'); +var Keys = require('react-keybinding'); var actions = require('../actions/actions'); -var actions = require('../actions/actions'); +var Filters = require('./filters'); var Header = React.createClass({ + mixins: [ + Keys + ], + + keybindings: { + 'i': function() { + actions.openModal('info'); + }, + 's': function() { + var geocoder = this.getDOMNode().querySelector('[data-hook="geocoder"]'); + geocoder.focus(); + // Prevent the 's' from being typed in the search box. + setTimeout(function() { + geocoder.value = ''; + }, 1); + } + }, aboutClickHandler: function(e) { e.preventDefault(); @@ -35,12 +53,7 @@ var Header = React.createClass({
    -
  • - Settings -
    -

    Settings go here.

    -
    -
  • +
diff --git a/app/assets/scripts/components/home.js b/app/assets/scripts/components/home.js index d5783351..0d281c21 100644 --- a/app/assets/scripts/components/home.js +++ b/app/assets/scripts/components/home.js @@ -1,6 +1,7 @@ 'use strict'; var React = require('react/addons'); var MapBoxMap = require('./map'); +var MiniMap = require('./minimap'); var ResultsPane = require('./results_pane'); var Home = React.createClass({ @@ -8,6 +9,7 @@ var Home = React.createClass({ return (
+
); diff --git a/app/assets/scripts/components/map.js b/app/assets/scripts/components/map.js index 1bfb987a..39aac0fe 100644 --- a/app/assets/scripts/components/map.js +++ b/app/assets/scripts/components/map.js @@ -2,16 +2,20 @@ require('mapbox.js'); var React = require('react/addons'); var Reflux = require('reflux'); +var Router = require('react-router'); +var _ = require('lodash'); var overlaps = require('turf-overlaps'); // Not working. Using cdn. (turf.intersect was throwing a weird error) //var turf = require('turf'); var actions = require('../actions/actions'); var mapStore = require('../stores/map_store'); var resultsStore = require('../stores/results_store'); +var searchQueryStore = require('../stores/search_query_store'); var utils = require('../utils/utils'); var dsZoom = require('../utils/ds_zoom'); +var config = require('../config.js'); -L.mapbox.accessToken = 'pk.eyJ1IjoiZGV2c2VlZCIsImEiOiJnUi1mbkVvIn0.018aLhX0Mb0tdtaT2QNe2Q'; +L.mapbox.accessToken = 'pk.eyJ1IjoiaG90IiwiYSI6IjU3MjE1YTYxZGM2YmUwMDIxOTg2OGZmNWU0NzRlYTQ0In0.MhK7SIwO00rhs3yMudBfIw'; var Map = React.createClass({ // Connect to the store "mapStore". Whenever the store calls "this.trigger()" @@ -20,6 +24,7 @@ var Map = React.createClass({ // removing the listener when the component is unmounted. mixins: [ Reflux.listenTo(mapStore, "onMapData"), + Reflux.listenTo(searchQueryStore, "onSearchQueryChanged"), Reflux.listenTo(actions.mapSquareSelected, "onMapSquareSelected"), Reflux.listenTo(actions.mapSquareUnselected, "onMapSquareUnselected"), Reflux.listenTo(actions.resultOver, "onResultOver"), @@ -28,8 +33,13 @@ var Map = React.createClass({ Reflux.listenTo(actions.resultItemView, "onResultItemView"), Reflux.listenTo(actions.resultListView, "onResultListView"), + Reflux.listenTo(actions.miniMapClick, "onMiniMapClick"), + Reflux.listenTo(actions.goToLatest, "onGoToLatest"), Reflux.listenTo(actions.geocoderResult, "onGeocoderResult"), + + Router.Navigation, + Router.State ], map: null, @@ -50,6 +60,10 @@ var Map = React.createClass({ // square that contains it. Check updateGrid() selectIntersecting: null, + // If there's a selected square in the path, we store it and then select the + // correct one when the grid is updating. + routerSelectedSquare: null, + getInitialState: function() { return { loading: true @@ -62,6 +76,26 @@ var Map = React.createClass({ mapData: data, loading: false }); + + var sqrFeature = mapStore.getSelectedSquare(); + if (sqrFeature !== null) { + var intersected = mapStore.getResultsIntersect(sqrFeature); + if (intersected.length > 0) { + actions.resultsChange(intersected); + } else { + actions.mapSquareUnselected(); + } + } + }, + + onSearchQueryChanged: function() { + this.setState({ loading: true }); + }, + + // Actions listener. + onMiniMapClick: function(latlng) { + // Remove footprint highlight. + this.map.setView(latlng); }, // Actions listener. @@ -98,7 +132,18 @@ var Map = React.createClass({ // Coordinates must be inverted for panTo. this.map.panTo([sqrFeature.properties.centroid[1], sqrFeature.properties.centroid[0]]); - this.updateGrid(); + // Set the correct path. + var params = this.getParams(); + var route = params.item_id ? 'item' : 'results'; + + var selectedSquare = mapStore.getSelectedSquareCenter(); + + params.map = this.mapViewToString(); + params.square = selectedSquare[1] + ',' + selectedSquare[0]; + + this.replaceWith(route, params, this.getQuery()); + + //this.updateGrid(); }, // Actions listener. @@ -107,6 +152,10 @@ var Map = React.createClass({ this.map.removeLayer(this.overImageLayer); } + // Set the correct path. + var mapLocation = this.mapViewToString(); + this.replaceWith('map', { map: mapLocation }, this.getQuery()); + actions.resultsChange([]); this.updateGrid(); }, @@ -135,7 +184,7 @@ var Map = React.createClass({ latest.properties.centroid = latestCenter; this.selectIntersecting = latest; // Move the map - this.map.setView([latestCenter.geometry.coordinates[1], latestCenter.geometry.coordinates[0]], 8); + this.map.setView([latestCenter.geometry.coordinates[1], latestCenter.geometry.coordinates[0]], config.map.initialZoom); } }, @@ -148,12 +197,13 @@ var Map = React.createClass({ }, // Redraws the line grid. - // This is a pixel grid with 200px squares at zoom level 8. + // This is a pixel grid with config.map.grid.pxSize squares + // at zoom level config.map.grid.atZoom. updateFauxGrid: function() { var bounds = this.map.getBounds(); var extent = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]; - var grid = this.linePixelGrid(extent, 200, 8); + var grid = this.linePixelGrid(extent, config.map.grid.pxSize, config.map.grid.atZoom); this.fauxLineGridLayer.clearLayers().addData(grid); this.fauxLineGridLayer.eachLayer(function(l) { @@ -184,20 +234,21 @@ var Map = React.createClass({ }, // Updates the colored grid. - // This is a pixel grid with 200px squares at zoom level 8. + // This is a pixel grid with config.map.grid.pxSize squares + // at zoom level config.map.grid.atZoom. // It is separated from the line grid to allow independent styling of // the stroke/content. updateGrid: function() { var _this = this; this.gridLayer.clearLayers(); - // Do not draw below zoom level 6 - if (this.map.getZoom() < 6) { return; } + // Do not draw below zoom level config.map.interactiveGridZoomLimit + if (this.map.getZoom() < config.map.interactiveGridZoomLimit) { return; } var bounds = this.map.getBounds(); var extent = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]; // Square grid to color. - var squareGrid = this.squarePixelGrid(extent, 200, 8); + var squareGrid = this.squarePixelGrid(extent, config.map.grid.pxSize, config.map.grid.atZoom); /* // How this works: @@ -279,11 +330,23 @@ var Map = React.createClass({ this.gridLayer.addData(squareGrid); this.gridLayer.eachLayer(function(l) { L.DomUtil.addClass(l._path, 'gs'); - var featureCenter = null; + var featureCenter = turf.centroid(l.feature); + l.feature.properties.featureCenter = featureCenter; + + // If there is a selected square in the path, select the correct one. + if (_this.routerSelectedSquare) { + if (_this.routerSelectedSquare[0] == featureCenter.geometry.coordinates[1] && + _this.routerSelectedSquare[1] == featureCenter.geometry.coordinates[0]) { + // Done with selecting. + _this.routerSelectedSquare = null; + // Trigger action. + actions.mapSquareSelected(l.feature); + return; + } + } // Select the square that intersects the stored image. if (_this.selectIntersecting) { - featureCenter = turf.centroid(l.feature); var latestFeature = utils.getPolygonFeature(_this.selectIntersecting.coordinates); if (turf.inside(featureCenter, latestFeature) || turf.inside(_this.selectIntersecting.properties.centroid, l.feature) || overlaps(latestFeature, l.feature)) { @@ -325,6 +388,15 @@ var Map = React.createClass({ L.DomUtil.addClass(l._path, 'gs-density-low'); } + var p = L.popup({ + autoPan: false, + closeButton: false, + offset: L.point(0, 10), + className: 'gs-tooltip-count' + }).setContent(intersectCount.toString()); + + l.bindPopup(p); + }); this.gridLayer.bringToBack(); return this; @@ -335,14 +407,30 @@ var Map = React.createClass({ componentDidMount: function() { console.log('componentDidMount MapBoxMap'); var _this = this; - var view = [60.177, 25.148]; + var view = config.map.initialView; + var zoom = config.map.initialZoom; + + // Map position from path. + var routerMap = this.getParams().map; + if (routerMap) { + routerMap = this.stringToMapView(routerMap); + view = [routerMap.lat, routerMap.lng]; + zoom = routerMap.zoom; + } + + // Check if there's a selected square in the path. + var routerSquare = this.getParams().square; + if (routerSquare) { + this.routerSelectedSquare = routerSquare.split(','); + view = [this.routerSelectedSquare[0], this.routerSelectedSquare[1]]; + } - this.map = L.mapbox.map(this.getDOMNode().querySelector('#map'), 'devseed.m9i692do', { + this.map = L.mapbox.map(this.getDOMNode().querySelector('#map'), config.map.baseLayer, { zoomControl: false, - minZoom : 4, - //maxZoom : 18, + minZoom : config.map.minZoom, + maxZoom : config.map.maxZoom, maxBounds: L.latLngBounds([-90, -180], [90, 180]) - }).setView(view, 6); + }).setView(view, zoom); // Custom zoom control. var zoom = new dsZoom({ @@ -360,6 +448,9 @@ var Map = React.createClass({ this.gridLayer = L.geoJson(null, { style: L.mapbox.simplestyle.style }).addTo(this.map); // On click select the square. this.gridLayer.on('click', function(e) { + // Ensure that the popup doesn't open. + e.layer.closePopup(); + // No previous square selected. if (mapStore.isSelectedSquare()) { // Unselect. @@ -377,22 +468,44 @@ var Map = React.createClass({ this.gridLayer.on('mouseover', function(e) { if (!mapStore.isSelectedSquare() && e.layer.feature.properties.intersectCount > 0) { L.DomUtil.addClass(e.layer._path, 'gs-highlight'); + // Open popup on square center. + var sqrCenter = e.layer.feature.properties.featureCenter.geometry.coordinates; + e.layer.openPopup([sqrCenter[1], sqrCenter[0]]); } }); // On mouseout remove gs-highlight. this.gridLayer.on('mouseout', function(e) { L.DomUtil.removeClass(e.layer._path, 'gs-highlight'); + e.layer.closePopup(); }); // Footprint layer. this.overFootprintLayer = L.geoJson(null, { style: L.mapbox.simplestyle.style }).addTo(this.map); + // Map move listener. - this.map.on('moveend', function() { - _this.setState({loading: true}); + var onMoveEnd = _.debounce(function() { + // Compute new map location for the path . + var mapLocation = _this.mapViewToString(); + // Preserve other params if any. + var params = _this.getParams(); + params.map = mapLocation; + + // Check what's the route to use. + var route = 'map'; + if (params.item_id) { + route = 'item'; + } + else if (params.square) { + route = 'results'; + } + _this.replaceWith(route, params, _this.getQuery()); + actions.mapMove(_this.map); _this.updateFauxGrid(); - }); + }, 300) + this.map.on('moveend', onMoveEnd); + this.map.on('movestart', onMoveEnd.cancel); // Create fauxGrid. this.updateFauxGrid(); @@ -417,6 +530,34 @@ var Map = React.createClass({ ); }, + /** + * Converts the map view (coords + zoom) to use on the path. + * + * @return string + */ + mapViewToString: function() { + var center = this.map.getCenter(); + var zoom = this.map.getZoom(); + return center.lat + ',' + center.lng + ',' + zoom; + }, + + /** + * Converts a path string like 60.359564131824214,4.010009765624999,6 + * to a readable object + * + * @param String + * string to convert + * @return object + */ + stringToMapView: function(string) { + var data = string.split(','); + return { + lat: data[0], + lng: data[1], + zoom: data[2], + } + }, + /** * Creates a equidistant pixel line grid for the given bbox. * The grid drawing will start form the closest cellSize multiple thus @@ -538,4 +679,4 @@ var Map = React.createClass({ }); -module.exports = Map; \ No newline at end of file +module.exports = Map; diff --git a/app/assets/scripts/components/minimap.js b/app/assets/scripts/components/minimap.js new file mode 100644 index 00000000..0b8baf09 --- /dev/null +++ b/app/assets/scripts/components/minimap.js @@ -0,0 +1,105 @@ +'use strict'; +require('mapbox.js'); +var React = require('react/addons'); +var Reflux = require('reflux'); +// Not working. Using cdn. (turf.intersect was throwing a weird error) +//var turf = require('turf'); +var actions = require('../actions/actions'); +var mapStore = require('../stores/map_store'); +var config = require('../config.js'); + +var MiniMap = React.createClass({ + mixins: [ + Reflux.listenTo(actions.mapMove, "onMapMove"), + Reflux.listenTo(actions.mapSquareSelected, "onMapSquareSelected"), + Reflux.listenTo(actions.mapSquareUnselected, "onMapSquareUnselected") + ], + + map: null, + + viewfinder: null, + targetLines: null, + + // Actions listener. + onMapMove: function(mainmap) { + var b = mainmap.getBounds(); + + this.viewfinder.setLatLngs([ + b.getNorthEast(), + b.getNorthWest(), + b.getSouthWest(), + b.getSouthEast() + ]).addTo(this.map); + }, + + onMapSquareSelected: function(sqrFeature) { + var center = sqrFeature.properties.centroid; + + this.targetLines.setLatLngs([ + [ + [-90, center[0]], + [90, center[0]] + ], + [ + [center[1], -220], + [center[1], 220] + ] + ]); + }, + + onMapSquareUnselected: function() { + this.targetLines.clearLayers(); + }, + + // Lifecycle method. + // Called once as soon as the component has a DOM representation. + componentDidMount: function() { + console.log('componentDidMount MiniMap'); + var _this = this; + + this.map = L.mapbox.map(this.getDOMNode(), config.map.baseLayer, { + center: [0, 0], + + zoomControl: false, + attributionControl: false, + dragging: false, + touchZoom: false, + scrollWheelZoom: false, + doubleClickZoom: false, + boxZoom: false, + + maxBounds: L.latLngBounds([-90, -180], [90, 180]) + }).fitBounds(L.latLngBounds([-90, -180], [90, 180])); + + this.viewfinder = L.polygon([], { + clickable: false, + color: '#1f3b45', + weight: 0.5 + }).addTo(this.map); + + + this.targetLines = L.multiPolyline([], { + clickable: false, + color: '#1f3b45', + weight: 0.5 + }).addTo(this.map); + + console.log(this.targetLines); + this.map.on('click', function(e) { + actions.miniMapClick(e.latlng); + }); + }, + + // Lifecycle method. + // Called when the component gets updated. + componentDidUpdate: function(/*prevProps, prevState*/) { + console.log('componentDidUpdate'); + }, + + render: function() { + return (
); + }, + +}); + +module.exports = MiniMap; \ No newline at end of file diff --git a/app/assets/scripts/components/modals/base_modal.js b/app/assets/scripts/components/modals/base_modal.js index 552ffb50..8aac837c 100644 --- a/app/assets/scripts/components/modals/base_modal.js +++ b/app/assets/scripts/components/modals/base_modal.js @@ -3,6 +3,7 @@ var React = require('react/addons'); var ReactCSSTransitionGroup = React.addons.CSSTransitionGroup; var Reflux = require('reflux'); +var Keys = require('react-keybinding'); var actions = require('../../actions/actions'); /** @@ -21,8 +22,15 @@ var actions = require('../../actions/actions'); var BModal = React.createClass({ mixins: [ Reflux.listenTo(actions.openModal, 'onOpenModal'), + Keys ], + keybindings: { + 'esc': function() { + this.setState({ revealed: false }); + } + }, + onOpenModal: function(which) { if (which == this.props.type) { this.setState({ revealed: true }); diff --git a/app/assets/scripts/components/modals/message_modal.js b/app/assets/scripts/components/modals/message_modal.js new file mode 100644 index 00000000..3e1e76d2 --- /dev/null +++ b/app/assets/scripts/components/modals/message_modal.js @@ -0,0 +1,52 @@ +var React = require('react/addons'); +var Reflux = require('reflux'); +var BModal = require('./base_modal'); +var actions = require('../../actions/actions'); + +var MessageModal = React.createClass({ + mixins: [ + Reflux.listenTo(actions.openModal, 'onOpenModal') + ], + + getInitialState: function() { + return { + title: 'Message', + message: '' + } + }, + + onOpenModal: function (which, data) { + if (which === 'message' && data) { + this.setState(data); + } + }, + + getHeader: function () { + return (

{this.state.title}

); + }, + + getBody: function() { + return ( +
+ {this.state.message} +
+ ); + }, + + getFooter: function () { + return false; + }, + + render: function () { + return ( + + ); + } +}); + +module.exports = MessageModal; diff --git a/app/assets/scripts/components/results_item.js b/app/assets/scripts/components/results_item.js index 3a0b24b6..7c450231 100644 --- a/app/assets/scripts/components/results_item.js +++ b/app/assets/scripts/components/results_item.js @@ -1,11 +1,36 @@ 'use strict'; +var qs = require('querystring'); +var $ = require('jquery'); var React = require('react/addons'); +var Keys = require('react-keybinding'); +var Router = require('react-router'); var actions = require('../actions/actions'); -var ZcInput = require('./shared/zc_input'); +var ZcButton = require('./shared/zc_button'); +var Dropdown = require('./shared/dropdown'); var utils = require('../utils/utils'); +var actions = require('../actions/actions'); +var prettyBytes = require('pretty-bytes'); var ResultsItem = React.createClass({ + mixins: [ + Keys, + Router.State + ], + + keybindings: { + 'arrow-left': function() { + if (this.props.pagination.current > 1) { + actions.prevResult(); + } + }, + 'arrow-right': function() { + if (this.props.pagination.current < this.props.pagination.total) { + actions.nextResult(); + } + } + }, + prevResult: function(e) { e.preventDefault(); actions.prevResult(); @@ -19,6 +44,47 @@ var ResultsItem = React.createClass({ actions.nextResult(); }, + onCopy: function(e) { + return this.getDOMNode().querySelector('[data-hook="copy:data"]').value; + }, + + onOpenJosm: function(d) { + var self = this; + var source = 'OpenAerialMap - ' + d.provider + ' - ' + d.uuid; + // Reference: + // http://josm.openstreetmap.de/wiki/Help/Preferences/RemoteControl#load_and_zoom + $.get('http://127.0.0.1:8111/load_and_zoom?' + qs.stringify({ + left: d.bbox[0], + right: d.bbox[2], + bottom: d.bbox[1], + top: d.bbox[3], + source: source + })) + .success(function (data) { + // Reference: + // http://josm.openstreetmap.de/wiki/Help/Preferences/RemoteControl#imagery + // Note: `url` needs to be the last parameter. + $.get('http://127.0.0.1:8111/imagery?' + qs.stringify({ + type: 'tms', + title: source + }) + '&url=' + d.properties.tms) + .success(function () { + // all good! + actions.openModal('message', { + title: 'Success', + message: 'This scene has been loaded into JOSM.' + }); + }); + }) + .fail(function (err) { + console.error(err); + actions.openModal('message', { + title: 'Error', + message:

Could not connect to JOSM via Remote Control. Is JOSM configured to allow remote control?

+ }); + }); + }, + render: function() { var d = this.props.data; var pagination = this.props.pagination; @@ -28,7 +94,32 @@ var ResultsItem = React.createClass({ var tmsOptions = null; if (d.properties.tms) { - tmsOptions = (); + // Generate the iD URL: + // grab centroid of the footprint + var centroid = turf.centroid(d.geojson).geometry.coordinates; + // cheat by using current zoom level + var zoom = this.getParams().map.split(',')[2] + var idUrl = 'http://www.openstreetmap.org/edit' + + '#map=' + [zoom, centroid[1], centroid[0]].join('/') + + '?' + qs.stringify({ + editor: 'id', + background: 'custom:' + d.properties.tms + }); + + tmsOptions = ( +
+ + + + +
+ ); } var blurImage = { @@ -52,14 +143,20 @@ var ResultsItem = React.createClass({ Download
-
Type
-
{d.properties.tms ? 'Multiscene TMS' : 'Single Scene'}
Date
{d.acquisition_start.slice(0,10)}
Resolution
{utils.gsdToUnit(d.gsd)}
+
Type
+
{d.properties.tms ? 'Image + Map Layer' : 'Image'}
+
Image Size
+
{prettyBytes(d.file_size)}
Platform
{d.platform}
+
Sensor
+
{d.properties.sensor ? d.properties.sensor : 'not available'}
+
Provider
+
{d.provider}
@@ -75,4 +172,4 @@ var ResultsItem = React.createClass({ } }) -module.exports = ResultsItem; \ No newline at end of file +module.exports = ResultsItem; diff --git a/app/assets/scripts/components/results_list.js b/app/assets/scripts/components/results_list.js index 3b89d2b9..297fa6a8 100644 --- a/app/assets/scripts/components/results_list.js +++ b/app/assets/scripts/components/results_list.js @@ -2,8 +2,10 @@ var React = require('react/addons'); var actions = require('../actions/actions'); var utils = require('../utils/utils'); +var mapStore = require('../stores/map_store'); var ResultsListItem = React.createClass({ + onClick: function(e) { e.preventDefault(); actions.resultItemSelect(this.props.data); @@ -33,7 +35,7 @@ var ResultsListItem = React.createClass({
Type
-
{d.properties.tms ? 'Multiscene TMS' : 'Single Scene'}
+
{d.properties.tms ? 'Image + Map Layer' : 'Image'}
Date
{d.acquisition_start.slice(0,10)}
Res
@@ -49,6 +51,10 @@ var ResultsListItem = React.createClass({ var ResultsList = React.createClass({ render: function() { + var square = mapStore.getSelectedSquareCenter(); + var north = Math.round(square[1] * 100000) / 100000; + var east = Math.round(square[0] * 100000) / 100000; + var numRes = this.props.results.length; var results = this.props.results.map(function(o) { return (); @@ -56,7 +62,7 @@ var ResultsList = React.createClass({ return (
-

Selection

+

{'N ' + north + ', E ' + east}

{numRes} results

diff --git a/app/assets/scripts/components/results_pane.js b/app/assets/scripts/components/results_pane.js index ed3347db..0098558d 100644 --- a/app/assets/scripts/components/results_pane.js +++ b/app/assets/scripts/components/results_pane.js @@ -1,6 +1,8 @@ 'use strict'; var React = require('react/addons'); var Reflux = require('reflux'); +var Router = require('react-router'); +var Keys = require('react-keybinding'); var ResultsList = require('./results_list'); var ResultsItem = require('./results_item'); var resultsStore = require('../stores/results_store'); @@ -8,7 +10,26 @@ var mapStore = require('../stores/map_store'); var actions = require('../actions/actions'); var ResultsPane = React.createClass({ - mixins: [Reflux.listenTo(resultsStore, "onResults")], + mixins: [ + Reflux.listenTo(resultsStore, "onResults"), + Router.Navigation, + Router.State, + Keys + ], + + keybindings: { + 'esc': function() { + if (this.state.results.length === 0) { + return; + } + actions.mapSquareUnselected(); + } + }, + + // We only want to load the item from the id in the path the first time the + // "page" is loaded. + // More on how the router works can be found in routes.js + loadFromRouter: true, onResults: function(data) { this.setState({ @@ -16,6 +37,19 @@ var ResultsPane = React.createClass({ selectedItem: data.selectedItem, selectedItemIndex: data.selectedItemIndex }); + + // No square selected. Do not update route. + if (!mapStore.isSelectedSquare()) { + return; + } + + var params = this.getParams(); + var route = 'results'; + if (data.selectedItem) { + route = 'item'; + params.item_id = data.selectedItem._id + } + this.replaceWith(route, params, this.getQuery()); }, getInitialState: function() { @@ -31,6 +65,23 @@ var ResultsPane = React.createClass({ actions.mapSquareUnselected(); }, + componentWillUpdate: function(nextProps, nextState) { + var params = this.getParams(); + // if: + // - didn't load already + // - there's an id in the url + // - there are results + if (this.loadFromRouter && params.item_id && nextState.results.length) { + this.loadFromRouter = false; + // Search for the item to trigger the action. + for (var i in nextState.results) { + if (nextState.results[i]._id == params.item_id) { + actions.resultItemSelect(nextState.results[i]); + } + } + } + }, + render: function() { var resultsPane = null; @@ -63,4 +114,4 @@ var ResultsPane = React.createClass({ } }) -module.exports = ResultsPane; \ No newline at end of file +module.exports = ResultsPane; diff --git a/app/assets/scripts/components/shared/dropdown.js b/app/assets/scripts/components/shared/dropdown.js new file mode 100644 index 00000000..2a4af7a8 --- /dev/null +++ b/app/assets/scripts/components/shared/dropdown.js @@ -0,0 +1,87 @@ +'use strict'; +var React = require('react'); +var Reflux = require('reflux'); +var $ = require('jquery'); +var ReactCSSTransitionGroup = React.addons.CSSTransitionGroup; + +var dropdownActions = Reflux.createActions({ + 'closeOthers': {} +}); + +var activeDropdowns = 0; + +var Dropdown = React.createClass({ + mixins: [Reflux.listenTo(dropdownActions.closeOthers, "onCloseOthers")], + + onCloseOthers: function($exception) { + if (this.getDOMNode() != $exception) { + this.setState({ open: false }); + } + }, + + bodyListener: function(e) { + var $clickedDropdown = $(e.target).parents('[data-hook="dropdown"]'); + dropdownActions.closeOthers($clickedDropdown[0]); + }, + + getDefaultProps: function() { + return { + element: 'div', + className: '', + + triggerTitle: '', + triggerClassName: '', + triggerText: '', + } + }, + + getInitialState: function() { + return { + open: false, + } + }, + + // Lifecycle method. + // Called once as soon as the component has a DOM representation. + componentDidMount: function() { + // With a cross Dropdown variable we ensure that only one event is setup. + if (++activeDropdowns === 1) { + // Namespace the event so it's easy to remove. + $(document).bind('click.dropdown', this.bodyListener); + } + }, + + // Lifecycle method. + // Called once as soon as the component has a DOM representation. + componentWillUnmount: function() { + if (--activeDropdowns === 0) { + $(document).unbind('click.dropdown'); + } + }, + + closeDropdown: function(e) { + e.preventDefault(); + this.setState({ open: !this.state.open }); + }, + + render: function() { + var klasses = ['drop']; + if (this.state.open) { + klasses.push('open'); + } + if (this.props.className) { + klasses.push(this.props.className); + } + + return ( + + {this.props.triggerText} +
+ {this.props.children} +
+
+ ); + } +}); + +module.exports = Dropdown; \ No newline at end of file diff --git a/app/assets/scripts/components/shared/zc_button.js b/app/assets/scripts/components/shared/zc_button.js new file mode 100644 index 00000000..7e76dda3 --- /dev/null +++ b/app/assets/scripts/components/shared/zc_button.js @@ -0,0 +1,57 @@ +'use strict'; +var React = require('react'); +var ZeroClipboard = require('zeroclipboard'); + +var ZcButton = React.createClass({ + propTypes: { + onCopy: React.PropTypes.func.isRequired, + }, + + getDefaultProps: function() { + return { + title: '', + className: '', + text: '', + } + }, + + // Lifecycle method. + // Called once as soon as the component has a DOM representation. + componentDidMount: function() { + ZeroClipboard.config({ + swfPath: "/ZeroClipboard.swf", + hoverClass: "zc-hover", + activeClass: "zc-active", + }); + + var _this = this; + var el = this.getDOMNode(); + var client = new ZeroClipboard(el); + client.on( "ready", function( readyEvent ) { + console.log( "ZeroClipboard SWF is ready!" ); + + L.DomUtil.removeClass(el, 'disabled'); + + client.on( 'copy', function(event) { + var toCopy = _this.props.onCopy(event); + if (toCopy === false) { + return; + } + event.clipboardData.setData('text/plain', toCopy); + }); + + }); + }, + + onCopyClick: function(e) { + e.preventDefault(); + }, + + render: function() { + return ( + {this.props.text} + ); + } +}) + +module.exports = ZcButton; \ No newline at end of file diff --git a/app/assets/scripts/components/shared/zc_input.js b/app/assets/scripts/components/shared/zc_input.js deleted file mode 100644 index 5e16e85f..00000000 --- a/app/assets/scripts/components/shared/zc_input.js +++ /dev/null @@ -1,57 +0,0 @@ -'use strict'; -var React = require('react'); -var ZeroClipboard = require('zeroclipboard'); - -var ZcInput = React.createClass({ - - // Lifecycle method. - // Called once as soon as the component has a DOM representation. - componentDidMount: function() { - ZeroClipboard.config({ - swfPath: "/ZeroClipboard.swf", - hoverClass: "zc-hover", - activeClass: "zc-active", - }); - - var client = new ZeroClipboard( this.getDOMNode().querySelector('[data-hook="copy:trigger"]') ); - client.on( "ready", function( readyEvent ) { - console.log( "ZeroClipboard SWF is ready!" ); - - client.elements().forEach(function(el) { - L.DomUtil.removeClass(el, 'disabled'); - el.addEventListener('mouseenter', function(event) { - event.target.setAttribute('data-title', 'Copy URL to clipboard'); - }, false); - }); - - client.on( 'copy', function(event) { - event.clipboardData.setData('text/plain', document.querySelector('[data-hook="copy:data"]').value); - event.target.setAttribute('data-title', 'Copied!'); - }); - - }); - }, - - render: function() { - return ( -
- - - {/* - - Options - - - */} -
- ); - } -}) - -module.exports = ZcInput; \ No newline at end of file diff --git a/app/assets/scripts/config.js b/app/assets/scripts/config.js new file mode 100644 index 00000000..3fa5c82e --- /dev/null +++ b/app/assets/scripts/config.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = { + map: { + baseLayer: 'hot.ml5mgnm7', + + initialZoom: 8, + minZoom: 8, + maxZoom: undefined, + + initialView: [60.177, 25.148], + + // Zoom below which the interactive grid ceases to exist. + interactiveGridZoomLimit: 8, + + grid: { + pxSize: 48, + atZoom: 8 + } + } +}; \ No newline at end of file diff --git a/app/assets/scripts/routes.js b/app/assets/scripts/routes.js index 6d785762..45610c43 100644 --- a/app/assets/scripts/routes.js +++ b/app/assets/scripts/routes.js @@ -14,9 +14,29 @@ var App = require('./components/app'); var About = require('./components/about'); var Home = require('./components/home'); +// Brief explanation on how the router works: +// The routing system was not built in from the beginning so adding one +// at this point would require quite a substantial refactor. +// A lighter and easier solution was adopted instead. +// The app still works by retaining the information in memory (i.e. storing the +// item the user clicked instead of getting it from the id in the url), but +// everytime there's a change the path gets replaces with the correct values. +// Replacing the path means that there's no history, so no going back. +// The url is shareable and the system will check for values in the url on load. +// It will only work on the first load. If a value is manually changed in the +// url, a simple "enter" won't make a difference. A hard refresh is necessary. + +// Bottom line is that the router works in reverse. Instead of updating the page +// with info from it, it gets updated with info from the actions on the page. + var routes = ( - - + + + + + + + ); diff --git a/app/assets/scripts/stores/map_store.js b/app/assets/scripts/stores/map_store.js index e5557864..f2e18d41 100644 --- a/app/assets/scripts/stores/map_store.js +++ b/app/assets/scripts/stores/map_store.js @@ -1,13 +1,17 @@ 'use strict'; +var qs = require('querystring'); var Reflux = require('reflux'); +var _ = require('lodash'); var $ = require('jquery'); var actions = require('../actions/actions'); +var searchQuery = require('./search_query_store') var overlaps = require('turf-overlaps'); var utils = require('../utils/utils'); module.exports = Reflux.createStore({ storage: { + searchParameters: { limit: 4000 }, results: [], sqrSelected: null, latestImagery: null @@ -16,9 +20,9 @@ module.exports = Reflux.createStore({ // Called on creation. // Setup listeners. init: function() { - this.listenTo(actions.mapMove, this.onMapMove); this.listenTo(actions.mapSquareSelected, this.onMapSquareSelected); this.listenTo(actions.mapSquareUnselected, this.onMapSquareUnselected); + this.listenTo(searchQuery, this.onSearchQuery); this.queryLatestImagery(); }, @@ -33,22 +37,56 @@ module.exports = Reflux.createStore({ }); }, - // Actions listener. - onMapMove: function(map) { + /** + * Translate the application-based search parameters into terms that the + * API understands, then hit the API and broadcast the result. + */ + onSearchQuery: function (parameters) { + console.log('onSearchQuery', parameters); var _this = this; + // hit API and broadcast result + if (parameters.bbox) { + var resolutionFilter = { + 'all': {}, + 'low': {gsd_from: 5}, // 5 + + 'medium': {gsd_from: 1, gsd_to: 5}, // 1 - 5 + 'high': {gsd_to: 1} // 1 + }[parameters.resolution]; + + var d = new Date(); + if (parameters.date === 'week') { + d.setDate(d.getDate() - 7); + } else if (parameters.date === 'month') { + d.setMonth(d.getMonth() - 1); + } else if (parameters.date === 'year') { + d.setFullYear(d.getFullYear() - 1); + } - if (map.getZoom() < 6) { - this.trigger([]); - return; - } + var dateFilter = parameters.date === 'all' ? {} : { + acquisition_from: [ + d.getFullYear(), + d.getMonth() + 1, + d.getDate() + ].join('-') + } - var bbox = map.getBounds().toBBoxString(); - // ?bbox=[lon_min],[lat_min],[lon_max],[lat_max] - $.get('http://oam-catalog.herokuapp.com/meta?limit=400&bbox=' + bbox) - .success(function(data) { - _this.storage.results = data.results; - _this.trigger(_this.storage.results); - }); + var typeFilter = parameters.dataType === 'all' ? {} : { has_tiled: true }; + + var params = _.assign({ + limit: 4000, + bbox: parameters.bbox, + }, resolutionFilter, dateFilter, typeFilter); + + console.log('search:', params); + + $.get('http://oam-catalog.herokuapp.com/meta?' + qs.stringify(params)) + .success(function(data) { + _this.storage.results = data.results; + _this.trigger(_this.storage.results); + }); + } else { + _this.trigger([]); + } }, // Actions listener. @@ -144,4 +182,4 @@ module.exports = Reflux.createStore({ return intersected; }, -}); \ No newline at end of file +}); diff --git a/app/assets/scripts/stores/results_store.js b/app/assets/scripts/stores/results_store.js index dd22d54c..040999f7 100644 --- a/app/assets/scripts/stores/results_store.js +++ b/app/assets/scripts/stores/results_store.js @@ -67,4 +67,4 @@ module.exports = Reflux.createStore({ this.trigger(this.storage); }, -}); \ No newline at end of file +}); diff --git a/app/assets/scripts/stores/search_query_store.js b/app/assets/scripts/stores/search_query_store.js new file mode 100644 index 00000000..574362d4 --- /dev/null +++ b/app/assets/scripts/stores/search_query_store.js @@ -0,0 +1,59 @@ +var Reflux = require('reflux'); +var actions = require('../actions/actions'); +var config = require('../config.js'); +var _ = require('lodash'); + +/** + * Models the "search parameters" from the point of view of the application. + * NOT responsible for undersatning the API -- that's done by map_store, which + * consumes this one. + */ +module.exports = Reflux.createStore({ + _parameters: { + date: 'all', + resolution: 'all', + dataType: 'all' + }, + + init: function () { + this.listenTo(actions.mapMove, this.onMapMove); + this.listenTo(actions.setDateFilter, this.onSetDateFilter); + this.listenTo(actions.setResolutionFilter, this.onSetResolutionFilter); + this.listenTo(actions.setDataTypeFilter, this.onSetDataTypeFilter); + }, + + onMapMove: function(map) { + if (map.getZoom() < config.map.interactiveGridZoomLimit) { + this._setParameter({bbox: null}); + return; + } + + var bbox = map.getBounds().toBBoxString(); + // ?bbox=[lon_min],[lat_min],[lon_max],[lat_max] + this._setParameter({bbox: bbox}); + }, + + onSetDateFilter: function(period) { + this._setParameter({date: period}); + }, + + onSetResolutionFilter: function(resolutionLevel) { + this._setParameter({resolution: resolutionLevel}); + }, + + onSetDataTypeFilter: function(type) { + this._setParameter({dataType: type}); + }, + + _setParameter: function(params) { + // update stored search params + _.assign(this._parameters, params); + for (var key in this._parameters) { + if (this._parameters[key] === null) { + delete this._parameters[key]; + } + } + + this.trigger(this._parameters) + } +}) diff --git a/app/assets/styles/_base.scss b/app/assets/styles/_base.scss index 0153b9e8..48576494 100644 --- a/app/assets/styles/_base.scss +++ b/app/assets/styles/_base.scss @@ -198,6 +198,9 @@ a:active{ @include columns(3); @include column-gap(2rem); margin-bottom: -0.75rem; + p, a { + -webkit-backface-visibility:hidden; + } h2 { @include heading(1.25rem); // 20 margin: 0 0 1rem 0; diff --git a/app/assets/styles/_icons.scss b/app/assets/styles/_icons.scss index 508d6e0a..2520af00 100644 --- a/app/assets/styles/_icons.scss +++ b/app/assets/styles/_icons.scss @@ -6,7 +6,7 @@ @font-face { font-family: 'oam-ib-ui-icons'; - src: url(data:application/x-font-ttf;charset=utf-8;base64,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) format('truetype'); + src: url(data:application/x-font-ttf;charset=utf-8;base64,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) format('truetype'); font-weight: normal; font-style: normal; } @@ -25,6 +25,16 @@ -moz-osx-font-smoothing: grayscale; } +.icon-id-editor { + @extend .icon; + content: "\e637"; +} + +.icon-josm { + @extend .icon; + content: "\e638"; +} + .icon-history { @extend .icon; content: "\e636"; diff --git a/app/assets/styles/_map.scss b/app/assets/styles/_map.scss index 259836f1..f81d3af5 100644 --- a/app/assets/styles/_map.scss +++ b/app/assets/styles/_map.scss @@ -63,4 +63,42 @@ .gs-highlight { stroke: rgba($base-color, 0.64); stroke-width: 1; +} + +.gs-tooltip-count { + & > * { + pointer-events: none; + } + .leaflet-popup-content-wrapper { + padding: 0; + background: none; + box-shadow: none; + } + .leaflet-popup-content { + @extend .antialiased; + text-align: center; + font-weight: $base-font-bold; + background: rgba($base-color, 0.48); + display: block; + color: #fff; + border-radius: $global-radius; + padding: 0 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + width: auto !important; + } + .leaflet-popup-tip-container { + display: none; + } +} + +#minimap { + position: absolute; + left: 1rem; + bottom: 1rem; + width: 16rem; + height: 10rem; + cursor: pointer; + + @extend %press-box-skin; } \ No newline at end of file diff --git a/app/assets/styles/_overlays.scss b/app/assets/styles/_overlays.scss index 555e038c..a2a6d4f7 100644 --- a/app/assets/styles/_overlays.scss +++ b/app/assets/styles/_overlays.scss @@ -107,8 +107,23 @@ } } -.drop-menu > li > a, -.drop-menu > li > a:visited { +.drop-menu .drop-menu-sectitle { + display: block; + padding: 0.375rem 1rem; + text-transform: uppercase; + font-size: 0.875rem; + line-height: 1.25rem; + color: tint($base-font-color, 32%); +} + +.drop-menu .drop-menu-sectitle:not(:first-child) { + box-shadow: inset 0 1px 0 0 $brdr-rgba; + margin-top: 0.5rem; + padding-top: 0.75rem; +} + +.drop-menu > * > a, +.drop-menu > * > a:visited { position: relative; display: block; padding: 0.375rem 1rem; @@ -122,7 +137,7 @@ } } -.drop-menu > li { +.drop-menu > * { &.has-icon-bef a, &.has-icon-aft a { &:before, @@ -150,6 +165,8 @@ &.facebook a:before { @extend .icon-facebook; } &.google-plus a:before { @extend .icon-google-plus; } &.clipboard a:before { @extend .icon-clipboard; } + &.id-editor a:before { @extend .icon-id-editor; } + &.josm a:before { @extend .icon-josm; } } .drop-menu > .active > a, diff --git a/package.json b/package.json index 3a364fbf..abe8c8c8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "devDependencies": { "browser-sync": "^2.5.3", "browserify": "^6.2.0", + "eslint-plugin-react": "^2.5.2", "glob": "^4.0.6", "gulp": "^3.8.11", "gulp-clean": "^0.3.1", @@ -29,7 +30,9 @@ "jquery": "^2.1.4", "lodash": "^3.8.0", "mapbox.js": "^2.1.9", + "pretty-bytes": "^2.0.1", "react": "^0.13.3", + "react-keybinding": "^2.0.0", "react-router": "^0.13.3", "reflux": "^0.2.7", "turf": "^2.0.2",