diff --git a/.gitignore b/.gitignore index 158673a..d517840 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ tmp spec/internal jetty .DS_Store +.idea/ diff --git a/README.md b/README.md index 6a34998..06e7abc 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,16 @@ [![Build Status](https://travis-ci.org/sul-dlss/blacklight-maps.png?branch=master)](https://travis-ci.org/sul-dlss/blacklight-maps) -Provides a map view for Blacklight search results. +Provides map views for Blacklight for items with geospatial coordinate (latitude/longitude) metadata. -![Screen shot](docs/map-view.png) -![Screen shot](docs/map-sidebar.png) +Browse all records by 'Map' view: +![Screen shot](docs/blacklight-maps_map-view.png) + +Map results view for search results (coordinate data as facet): +![Screen shot](docs/blacklight-maps_index-view.png) + +Maplet widget in item detail view: +![Screen shot](docs/blacklight-maps_show-view.png) ## Installation @@ -27,69 +33,141 @@ Run Blacklight-Maps generator: ## Usage -Blacklight-Maps adds a map view capability for a results set that contains geospatial coordinates (latitude/longitude). +Blacklight-Maps integrates [Leaflet](http://leafletjs.com/) to add map view capabilities for items with geospatial data in their corresponding Solr record. -For now, Blacklight-Maps requires that your Solr index include one of the following two types of fields: +In the map views, locations are represented as markers (or marker clusters, depending on the zoom level). Clicking on a marker opens a popup which (depending on config settings) displays the location name or coordinates, and provides a link to search for other items with the same location name/coordinates. -1. A `location_rpt` field that contains a bounding box for the document. For more on `location_rpt` see [Solr help](https://cwiki.apache.org/confluence/display/solr/Spatial+Search). This field can be multivalued. -``` - place_bbox: 44.0318907 25.0594286 63.3333366 39.7816755 - # minX minY maxX maxY -``` +Users can also run a search using the map bounds as coordinate parameters by clicking the ![search control](docs/blacklight-maps_search-control.png) search control in the map view. Any items with coordinates or bounding boxes that are contained within the current map window will be returned. + +In the catalog#map and catalog#index views, the geospatial data to populate the map comes from the facet component of the Solr response. Bounding boxes are represented as points corresponding to the center of the box. + +In the catalog#show view, the data simply comes from the main document. Points are represented as markers and bounding boxes are represented as polygons. Clicking on a polygon opens a popup that allows the user to search for any items intersecting the bounding box. + +### Solr Requirements + +Blacklight-Maps requires that your Solr index include at least one (but preferably BOTH) of the following two types of fields: + +1. A `location_rpt` field that contains coordinates or a bounding box. For more on `location_rpt` see [Solr help](https://cwiki.apache.org/confluence/display/solr/Spatial+Search). This field can be multivalued. + + ``` + # coordinates: lon lat or lat,lon + # bounding box: minX minY maxX maxY + coordinates_field: + - 78.96288 20.593684 + - 20.593684,78.96288 + - 68.162386 6.7535159 97.395555 35.5044752 + ``` + +2. An indexed, stored string field containing a properly-formatted [GeoJSON](http://geojson.org) feature object for a point or bounding box that includes the coordinates and (preferably) location name. This field can be multivalued. + + ``` + # first example below is for coordinate point, second is for bounding box + geojson_ssim: + - {"type":"Feature","geometry":{"type":"Point","coordinates":[78.96288,20.593684]},"properties":{"placename":"India"}} + - {"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[68.162386, 6.7535159], [97.395555, 6.7535159], [97.395555, 35.5044752], [68.162386, 35.5044752], [68.162386, 6.7535159]]]},"bbox":[68.162386, 6.7535159, 97.395555, 35.5044752]} + ``` + + If you have #2 above and you want the popup search links to use the location name as a search parameter, you also need: + +3. An indexed, stored text or string field containing location names. This field can be multivalued. + + ``` + placename_field: India + ``` + +##### Why so complicated? +Blacklight-Maps can be used with either field type (#1 or #2), however to take advantage of the full feature set, it is preferred that both field types exist for each item with geospatial metadata. + +* The GeoJSON field (#2 above) provides reliable association of place names with coordinates, so the map marker popups can display the location name +* The Location name field (#3 above) allows users to run meaningful searches for locations found on the map +* The Coordinate field (#1 above) provides for the "Search" function on the map in the catalog#map and catalog#index views + + +**Important:** If you are NOT using the geojson field (#2), you should create a `copyField` in your Solr schema.xml to copy the coordinates from the `location_rpt` field to a string field that is stored, indexed, and multivalued to allow for proper faceting of the coordinate values in the catalog#map and catalog#index views. -2. A field containing placenames with latitude and longitude coordinates delimited by `-|-`. The delimiter can be configured in `app/controllers/catalog_controller.rb`. This field can be multivalued. -``` - placename_coords: - - China-|-35.86166-|-104.195397 - - Tibet-|-29.646923-|-91.117212 - - India-|-20.593684-|-78.96288 +``` + + + + ``` -Note: We are looking at implementing support for additional fields. +Support for additional field types may be added in the future. ### Configuration #### Required -Blacklight-Maps expects you to provide: - -- the type of location field you are using, `placename_coord` or `bbox` (`bbox` is default) -- a field to map the placename coordinates or bbox field +Blacklight-Maps expects you to provide these configuration options: + ++ `facet_mode` = the type of field containing the data to use to display locations on the map (values: `'geojson'` or `'coordinates'`) + - if `'geojson'`: + + `geojson_field` = the name of the Solr field containing the GeoJSON data + + `placename_property` = the key in the GeoJSON properties hash representing the location name + - if `'coordinates'` + + `coordinates_facet_field` = the name of the Solr field containing coordinate data in string format (`` of `coordinates_field`) ++ `search_mode` = the type of search to run when clicking a link in the map popups (values: `'placename'` or `'coordinates'`) + - if `'placename'`: + + `placename_field` = the name of the Solr field containing the location names ++ `coordinates_field` = the name of the Solr `location_rpt` type field containing geospatial coordinate data #### Optional -- the maxZoom [property of the map](http://leafletjs.com/reference.html#map-maxzoom) -- a [tileLayer url](http://leafletjs.com/reference.html#tilelayer-l.tilelayer) to change the basemap -- an [attribution string](http://leafletjs.com/reference.html#tilelayer-attribution) to describe the basemap layer -- a custom delimiter field (used to delimit placename_coord values) +- `show_initial_zoom` = the zoom level to be used in the catalog#show view map (zoom levels for catalog#map and catalog#index map views are computed automatically) +- `maxzoom` = the maxZoom [property of the map](http://leafletjs.com/reference.html#map-maxzoom) +- `tileurl` = a [tileLayer url](http://leafletjs.com/reference.html#tilelayer-l.tilelayer) to change the basemap +- `mapattribution` = an [attribution string](http://leafletjs.com/reference.html#tilelayer-attribution) to describe the basemap layer +- `spatial_query_dist` = the radial distance, in kilometers, to search from a supplied coordinate point in a spatial search. This corresponds to the `d` [Spatial Filter](https://cwiki.apache.org/confluence/display/solr/Spatial+Search) parameter in Solr. + All of these options can easily be configured in `CatalogController.rb` in the `config` block. -``` +```ruby ... configure_blacklight do |config| - ## Default parameters to send to solr for all search-like requests. See also SolrHelper#solr_search_params - config.default_solr_params = { - :qt => 'search', - :rows => 10, - :fl => '*' - } - - ## Default values - config.view.maps.type = "bbox" # also accepts 'placename_coord' to use the placename coordinate type - config.view.maps.bbox_field = "place_bbox" - config.view.maps.placename_coord_field = "placename_coords" + ## blacklight-maps configuration default values + config.view.maps.geojson_field = "geojson" + config.view.maps.placename_property = "placename" + config.view.maps.coordinates_field = "coordinates" + config.view.maps.search_mode = "placename" # or "coordinates" + config.view.maps.spatial_query_dist = 0.5 + config.view.maps.placename_field = "placename_field" + config.view.maps.coordinates_facet_field = "coordinates_facet_field" + config.view.maps.facet_mode = "geojson" # or "coordinates" config.view.maps.tileurl = "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" - config.view.maps.attribution = 'Map data © OpenStreetMap contributors, CC-BY-SA' - config.view.maps.placename_coord_delimiter = '-|-' + config.view.maps.mapattribution = 'Map data © OpenStreetMap contributors, CC-BY-SA' + config.view.maps.maxzoom = 18 + config.view.maps.show_initial_zoom = 5 ... ``` +### Implementation + +The catalog#map and catalog#index map views are available by default. The "browse everything" Map view will be available in your app at `/map`, and in your app using routing helper `map_path`. + +However, the catalog#show maplet widget must be included manually, via one of two ways: + +1. Include the catalog/show_maplet_default partial explicitly. This option gives you the most flexibility, as you can choose where the partial gets rendered. + + ```ruby + <%= render partial: 'catalog/show_maplet_default' %> + ``` + +2. Add `:show_maplet` to the list of partials to be rendered automatically by Blacklight in `CatalogController.rb` in the `config` block. This option is less work up front, but it may be more difficult to customize how the maplet is integrated into the page layout. + + ``` + ... + configure_blacklight do |config| + # add :show_maplet to the show partials array + config.show.partials << :show_maplet + ... + ``` ## Contributing 1. Fork it ( http://github.com//blacklight-maps/fork ) 2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request +3. Make some changes (with [tests](https://github.com/projectblacklight/blacklight/wiki/testing), please) +4. Commit your changes (`git commit -am 'Add some feature'`) +5. Push to the branch (`git push origin my-new-feature`) +6. Create new Pull Request diff --git a/app/assets/javascripts/blacklight-maps.js b/app/assets/javascripts/blacklight-maps.js index 4777019..1e1bea3 100644 --- a/app/assets/javascripts/blacklight-maps.js +++ b/app/assets/javascripts/blacklight-maps.js @@ -3,6 +3,5 @@ //= require leaflet //= require leaflet.markercluster -//= require L.Control.Sidebar -//= require_tree './blacklight-maps' \ No newline at end of file +//= require_tree . \ No newline at end of file diff --git a/app/assets/javascripts/blacklight-maps/blacklight-maps-browse.js b/app/assets/javascripts/blacklight-maps/blacklight-maps-browse.js index 7e77a80..d530a22 100644 --- a/app/assets/javascripts/blacklight-maps/blacklight-maps-browse.js +++ b/app/assets/javascripts/blacklight-maps/blacklight-maps-browse.js @@ -3,14 +3,53 @@ $.fn.blacklight_leaflet_map = function(geojson_docs, arg_opts) { var map, sidebar, markers, geoJsonLayer, currentLayer; - // Update page links with number of mapped items - $('.page_links').append('' + geojson_docs.features.length + ' mapped'); + var mapped_items = '' + geojson_docs.features.length + ' location' + (geojson_docs.features.length !== 1 ? 's' : '') + ' mapped'; + + var mapped_caveat = 'Only items with location data are shown below'; + + var sortAndPerPage = $('#sortAndPerPage'); + + // Update page links with number of mapped items, disable sort, per_page, pagination + if (sortAndPerPage.length) { // catalog#index and #map view + var page_links = sortAndPerPage.find('.page_links'); + var result_count = page_links.find('.page_entries').find('strong').last().html(); + page_links.html('' + result_count + ' items found' + mapped_items + mapped_caveat); + sortAndPerPage.find('.dropdown-toggle').hide(); + + // clusters should show item result count in #index and #map views + var clusterIconFunction = function (cluster) { + var markers = cluster.getAllChildMarkers(); + var childCount = 0; + for (var i = 0; i < markers.length; i++) { + childCount += markers[i].feature.properties.hits; + } + var c = ' marker-cluster-'; + if (childCount < 10) { + c += 'small'; + } else if (childCount < 100) { + c += 'medium'; + } else { + c += 'large'; + } + return new L.divIcon({ html: '
' + childCount + '
', className: 'marker-cluster' + c, iconSize: new L.Point(40, 40) }); + }; + } else { // catalog#show view + $(this.selector).before(mapped_items); + var clusterIconFunction = this._defaultIconCreateFunction; + } // Configure default options and those passed via the constructor options var options = $.extend({ tileurl : 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', mapattribution : 'Map data © OpenStreetMap contributors, CC-BY-SA', - sidebar: 'blacklight-map-sidebar' + viewpoint: [0,0], + initialzoom: 2, + singlemarkermode: true, + searchcontrol: false, + catalogpath: 'catalog', + searchctrlcue: 'Search for all items within the current map window', + placenamefield: 'placename_field', + nodata: 'Sorry, there is no data for this location.' }, arg_opts ); // Extend options from data-attributes @@ -21,135 +60,122 @@ options.id = this.id; // Setup Leaflet map - map = L.map(this.id).setView([0,0], 2); + map = L.map(this.id); + + // set the viewpoint and zoom + if (options.viewpoint[0].constructor === Array) { + map.fitBounds(options.viewpoint, + { + padding:[10,10], + maxZoom:options.maxzoom + }); + } else { + map.setView(options.viewpoint, options.initialzoom); + } + L.tileLayer(options.tileurl, { attribution: options.mapattribution, maxZoom: options.maxzoom }).addTo(map); - // Initialize sidebar - sidebar = L.control.sidebar(options.sidebar, { - position: 'right', - autoPan: false - }); - - // Adds leaflet-sidebar control to map - map.addControl(sidebar); - // Create a marker cluster object and set options markers = new L.MarkerClusterGroup({ - showCoverageOnHover: false, - spiderfyOnMaxZoom: false, - singleMarkerMode: true, - animateAddingMarkers: true + singleMarkerMode: options.singlemarkermode, + iconCreateFunction: clusterIconFunction }); geoJsonLayer = L.geoJson(geojson_docs, { onEachFeature: function(feature, layer){ - layer.defaultOptions.title = getMapTitle(options.type, feature.properties.name); - layer.on('click', function(e){ - var placenames = {}; - placenames[layer.defaultOptions.title] = [feature.properties.html]; - setupSidebarDisplay(e,placenames); - }); + if (feature.properties.popup) { + layer.bindPopup(feature.properties.popup); + } else { + layer.bindPopup(options.nodata); + } } }); // Add GeoJSON layer to marker cluster object markers.addLayer(geoJsonLayer); - // Add marker cluster object to map + // Add markers to map map.addLayer(markers); - // Listeners for marker cluster clicks - markers.on('clusterclick', function(e){ - hideSidebar(); + // create overlay for search control hover + var searchHoverLayer = L.rectangle([[0,0], [0,0]], { + color: "#0033ff", + weight: 5, + opacity: 0.5, + fill: true, + fillColor: "#0033ff", + fillOpacity: 0.2 + }); - //if map is at the lowest zoom level - if (map.getZoom() === options.maxzoom){ + // create search control + var searchControl = L.Control.extend({ - var placenames = generatePlacenamesObject(e.layer.getAllChildMarkers()); - setupSidebarDisplay(e,placenames); - } - }); + options: { position: 'topleft' }, - //Add click listener to map - map.on('click drag', hideSidebar); + onAdd: function (map) { + var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); + this.link = L.DomUtil.create('a', 'leaflet-bar-part search-control', container); + this.link.title = options.searchctrlcue; + this.icon = L.DomUtil.create('i', 'glyphicon glyphicon-search', this.link); - }); + L.DomEvent.addListener(this.link, 'click', _search); - function setupSidebarDisplay(e, placenames){ - hideSidebar(); - offsetMap(e); - if (currentLayer !== e.layer || !("layer" in e)){ - // Update sidebar div with new html - $('#' + options.sidebar).html(buildList(placenames)); + L.DomEvent.addListener(this.link, 'mouseover', function () { + searchHoverLayer.setBounds(map.getBounds()); + map.addLayer(searchHoverLayer); + }); - // Scroll sidebar div to top - $('#' + options.sidebar).scrollTop(0); - currentLayer = e.layer; - } + L.DomEvent.addListener(this.link, 'mouseout', function () { + map.removeLayer(searchHoverLayer); + }); - // Show the sidebar - sidebar.show(); + return container; + } - } + }); - // Hides sidebar if it is visible - function hideSidebar(){ - if (sidebar.isVisible()){ - sidebar.hide(); + // add search control to map + if (options.searchcontrol === true) { + map.addControl(new searchControl()); } - } - // Build the list - function buildList(placenames){ - var html = ""; - $.each(placenames, function(i,val){ - html += "

" + i + "

"; - html += ""; - }); - return html; - } + }); - // Generates placenames object - function generatePlacenamesObject(markers){ - var placenames = {}; - $.each(markers, function(i,val){ - if (!(val.defaultOptions.title in placenames)){ - placenames[val.defaultOptions.title] = []; - } - placenames[val.defaultOptions.title].push(val.feature.properties.html); - }); - return placenames; + // remove stale params, add new params, and run a new search + function _search() { + var params = filterParams(['view', 'spatial_search_type', 'coordinates', 'f%5B' + options.placenamefield + '%5D%5B%5D']), + bounds = map.getBounds().toBBoxString().split(',').map(function(coord) { + if (parseFloat(coord) > 180) { + coord = '180' + } else if (parseFloat(coord) < -180) { + coord = '-180' + } + return Math.round(parseFloat(coord) * 1000000) / 1000000; + }), + coordinate_params = '[' + bounds[1] + ',' + bounds[0] + ' TO ' + bounds[3] + ',' + bounds[2] + ']'; + params.push('coordinates=' + encodeURIComponent(coordinate_params), 'spatial_search_type=bbox'); + $(location).attr('href', options.catalogpath + '?' + params.join('&')); } - // Move the map so that it centers the clicked cluster TODO account for various size screens - function offsetMap(e){ - var mapWidth = $('#' + options.id).width(); - var mapHeight = $('#' + options.id).height(); - if (!e.latlng.equals(map.getCenter())){ - map.panBy([(e.originalEvent.layerX - (mapWidth/4)), (e.originalEvent.layerY - (mapHeight/2))]); - }else{ - map.panBy([(mapWidth/4), 0]); + // remove unwanted params + function filterParams(filterList) { + var querystring = window.location.search.substr(1), + params = []; + if (querystring !== "") { + params = $.map(querystring.split('&'), function(value) { + if ($.inArray(value.split('=')[0], filterList) > -1) { + return null; + } else { + return value; + } + }); } + return params; } }; - function getMapTitle(type, featureName){ - switch(type){ - case 'bbox': - return 'Results'; - case 'placename_coord': - return featureName; - default: - return 'Results'; - } - } - }( jQuery )); diff --git a/app/assets/stylesheets/blacklight_maps/blacklight-maps.css.scss b/app/assets/stylesheets/blacklight_maps/blacklight-maps.css.scss index 6cdea43..07582af 100644 --- a/app/assets/stylesheets/blacklight_maps/blacklight-maps.css.scss +++ b/app/assets/stylesheets/blacklight_maps/blacklight-maps.css.scss @@ -1,4 +1,8 @@ /* Master manifest file for engine, so local app can require * this one file, but get all our files -- and local app * require does not need to change if we change file list. - */ \ No newline at end of file + */ + +@charset "UTF-8"; + +@import 'default'; \ No newline at end of file diff --git a/app/assets/stylesheets/blacklight_maps/default.css.scss b/app/assets/stylesheets/blacklight_maps/default.css.scss index cec3f9f..2dacdf2 100644 --- a/app/assets/stylesheets/blacklight_maps/default.css.scss +++ b/app/assets/stylesheets/blacklight_maps/default.css.scss @@ -1,27 +1,89 @@ @import 'leaflet'; @import 'leaflet.markercluster'; @import 'leaflet.markercluster.default'; -@import 'L.Control.Sidebar'; + +body.blacklight-catalog-map { + + #map_leader_text { + margin-bottom: 10px; + } + + .view-type { + display:none; + } + +} .view-icon-maps { &:before { content: "\e135"; } } -#blacklight-map{ - height: 550px; +#sortAndPerPage { + + .page_links { + + .mapped-count { + margin-left: 7px; + color: dimgray; + } + + .mapped-caveat { + margin-left: 7px; + font-size: 12px; + color: darkgray; + } + + } + +} + +#documents { + + #blacklight-index-map { + height: 550px; + margin: 10px 0; + + & ~ div.record-padding { + + div.pagination { + display: none; + } + + } + + } + +} + +#document #blacklight-show-map { + height: 300px; } -.badge.mapped-count { - margin-left: 8px; +.mapped-count .badge { vertical-align: text-bottom; } -.sidebar-thumb{ - height: 64px; - width: 64px; +a.leaflet-bar-part.search-control { + cursor: pointer; +} + +/* Portrait tablet to landscape and desktop */ +@media (min-width: 768px) and (max-width: 991px) { + + #sortAndPerPage { + + .page_links { + width: 75%; + padding: 0 12px 0 0; + + .mapped-caveat { + margin-left: 0; + float: left; + } + + } + + } + } -.sidebar-list{ - padding-left: 0; - list-style: none; -} \ No newline at end of file diff --git a/app/helpers/blacklight/blacklight_maps_helper_behavior.rb b/app/helpers/blacklight/blacklight_maps_helper_behavior.rb new file mode 100644 index 0000000..a21b972 --- /dev/null +++ b/app/helpers/blacklight/blacklight_maps_helper_behavior.rb @@ -0,0 +1,125 @@ +module Blacklight::BlacklightMapsHelperBehavior + + # @param [String] id the html id + # @param [Hash] tag_options options to put on the tag + def blacklight_map_tag id, tag_options = {}, &block + default_data = { + maxzoom: blacklight_config.view.maps.maxzoom, + tileurl: blacklight_config.view.maps.tileurl, + mapattribution: blacklight_config.view.maps.mapattribution + } + + options = {id: id, data: default_data}.deep_merge(tag_options) + if block_given? + content_tag(:div, options, &block) + else + tag(:div, options) + end + end + + # return the placename value to be used as a link + def placename_value(geojson_hash) + geojson_hash[:properties][blacklight_config.view.maps.placename_property.to_sym] + end + + # create a link to a bbox spatial search + def link_to_bbox_search bbox_coordinates + coords_for_search = bbox_coordinates.map { |v| v.to_s } + link_to(t('blacklight.maps.interactions.bbox_search'), + catalog_index_path(spatial_search_type: "bbox", + coordinates: "[#{coords_for_search[1]},#{coords_for_search[0]} TO #{coords_for_search[3]},#{coords_for_search[2]}]")) + end + + # create a link to a location name facet value + def link_to_placename_field field_value, field, displayvalue = nil + if params[:f] && params[:f][field] && params[:f][field].include?(field_value) + new_params = params + else + new_params = add_facet_params(field, field_value) + end + link_to(displayvalue.presence || field_value, + catalog_index_path(new_params.except(:view, :id, :spatial_search_type, :coordinates))) + end + + # create a link to a spatial search for a set of point coordinates + def link_to_point_search point_coordinates + new_params = params.except(:controller, :action, :view, :id, :spatial_search_type, :coordinates) + new_params[:spatial_search_type] = "point" + new_params[:coordinates] = "#{point_coordinates[1]},#{point_coordinates[0]}" + link_to(t('blacklight.maps.interactions.point_search'), catalog_index_path(new_params)) + end + + # return the facet field containing geographic data + def map_facet_field + blacklight_config.view.maps.facet_mode == "coordinates" ? + blacklight_config.view.maps.coordinates_facet_field : + blacklight_config.view.maps.geojson_field + end + + # return an array of Blacklight::SolrResponse::Facets::FacetItem items + def map_facet_values + if @response.facet_by_field_name(map_facet_field) + @response.facet_by_field_name(map_facet_field).items + else + [] + end + end + + # render the location name for the Leaflet popup + # separate from BlacklightMapsHelperBehavior#placename_value so + # location name display can be easily customized + def render_placename_heading(geojson_hash) + geojson_hash[:properties][blacklight_config.view.maps.placename_property.to_sym] + end + + # render the map for #index and #map views + def render_index_map + render :partial => 'catalog/index_map', + :locals => {:geojson_features => serialize_geojson(map_facet_values)} + end + + # determine the type of spatial search to use based on coordinates (bbox or point) + def render_spatial_search_link coordinates + if coordinates.length == 4 + link_to_bbox_search(coordinates) + else + link_to_point_search(coordinates) + end + end + + # pass the document or facet values to BlacklightMaps::GeojsonExport + def serialize_geojson(documents) + export = BlacklightMaps::GeojsonExport.new(controller, + controller.action_name, + documents) + export.to_geojson + end + + # determine the best viewpoint for the map so all markers are visible + def set_viewpoint(geojson_features) + viewpoint = nil + geojson_docs = JSON.parse(geojson_features)["features"] + if !geojson_docs.blank? + if geojson_docs.length == 1 + viewpoint = geojson_docs[0]["bbox"] ? nil : geojson_docs[0]["geometry"]["coordinates"].reverse + end + if geojson_docs.length > 1 || !viewpoint + longs, lats = [[],[]] + geojson_docs.each do |feature| + if feature["bbox"] + feature["bbox"].values_at(0,2).each {|long| longs << long } + feature["bbox"].values_at(1,3).each {|lat| lats << lat } + else + longs << feature["geometry"]["coordinates"][0] + lats << feature["geometry"]["coordinates"][1] + end + end + sorted_longs, sorted_lats = longs.sort, lats.sort + viewpoint = [[sorted_lats.first,sorted_longs.first],[sorted_lats.last,sorted_longs.last]] + end + end + viewpoint = [0,0] if !viewpoint + viewpoint + end + +end \ No newline at end of file diff --git a/app/helpers/blacklight_maps_helper.rb b/app/helpers/blacklight_maps_helper.rb index 1285942..24f2d26 100644 --- a/app/helpers/blacklight_maps_helper.rb +++ b/app/helpers/blacklight_maps_helper.rb @@ -1,26 +1,3 @@ -# Helper methods used for Blacklight Maps module BlacklightMapsHelper - # @param [String] id the html id - # @param [Hash] tag_options options to put on the tag - def blacklight_map_tag id, tag_options = {}, &block - default_data = { - maxzoom: blacklight_config.view.maps.maxzoom, - tileurl: blacklight_config.view.maps.tileurl, - type: blacklight_config.view.maps.type, - mapattribution: blacklight_config.view.maps.mapattribution - } - - options = {id: id, data: default_data}.deep_merge(tag_options) - if block_given? - content_tag(:div, options, &block) - else - tag(:div, options) - end - end - - def serialize_geojson - export = BlacklightMaps::GeojsonExport.new(controller, - @response.docs) - export.to_geojson - end + include Blacklight::BlacklightMapsHelperBehavior end diff --git a/app/views/catalog/_document_maps.html.erb b/app/views/catalog/_document_maps.html.erb index fe459da..c585532 100644 --- a/app/views/catalog/_document_maps.html.erb +++ b/app/views/catalog/_document_maps.html.erb @@ -1,6 +1,4 @@ <% # container for all documents in map view -%>
- <%= blacklight_map_tag('blacklight-map') %> -
- <%= javascript_tag "$('#blacklight-map').blacklight_leaflet_map(#{serialize_geojson});" %> + <%= render_index_map %>
diff --git a/app/views/catalog/_index_map.html.erb b/app/views/catalog/_index_map.html.erb new file mode 100644 index 0000000..930ae36 --- /dev/null +++ b/app/views/catalog/_index_map.html.erb @@ -0,0 +1,7 @@ +<%= blacklight_map_tag('blacklight-index-map', + {data:{viewpoint: set_viewpoint(geojson_features), + searchcontrol: true, + catalogpath: catalog_index_path, + placenamefield: blacklight_config.view.maps.placename_field + }}) %> +<%= javascript_tag "$('#blacklight-index-map').blacklight_leaflet_map(#{geojson_features});" %> \ No newline at end of file diff --git a/app/views/catalog/_index_maps.html.erb b/app/views/catalog/_index_maps.html.erb deleted file mode 100644 index c96086e..0000000 --- a/app/views/catalog/_index_maps.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -<% # the way each document will be viewed in the sidebar list -%> -
  • - <%= render_thumbnail_tag document, {class: 'sidebar-thumb media-object'}, {class: 'pull-left'} %> -
    -

    - <%= link_to_document document, document_show_link_field(document) %> -

    -
    -
  • diff --git a/app/views/catalog/_map_placename_search.html.erb b/app/views/catalog/_map_placename_search.html.erb new file mode 100644 index 0000000..03c2d64 --- /dev/null +++ b/app/views/catalog/_map_placename_search.html.erb @@ -0,0 +1,8 @@ +<% # content for the popup for a point feature - run a new search for this location -%> +
    + <%= render_placename_heading(geojson_hash) %> + <%= content_tag(:small, pluralize(hits, t('blacklight.maps.interactions.item'))) if hits %> +
    +<%= link_to_placename_field(placename_value(geojson_hash), + blacklight_config.view.maps.placename_field, + t('blacklight.maps.interactions.placename_search')) %> \ No newline at end of file diff --git a/app/views/catalog/_map_spatial_search.html.erb b/app/views/catalog/_map_spatial_search.html.erb new file mode 100644 index 0000000..4effc59 --- /dev/null +++ b/app/views/catalog/_map_spatial_search.html.erb @@ -0,0 +1,6 @@ +<% # content for the popup for a point or bbox feature - run a new coordinate search for this location -%> +
    + <%= coordinates.length == 2 ? coordinates.reverse : coordinates %> + <%= content_tag(:small, pluralize(hits, t('blacklight.maps.interactions.item'))) if hits %> +
    +<%= render_spatial_search_link(coordinates) %> \ No newline at end of file diff --git a/app/views/catalog/_show_maplet_default.html.erb b/app/views/catalog/_show_maplet_default.html.erb new file mode 100644 index 0000000..23a8bba --- /dev/null +++ b/app/views/catalog/_show_maplet_default.html.erb @@ -0,0 +1,11 @@ +<% # map for catalog#show view %> +
    + <% if @document[blacklight_config.view.maps.geojson_field.to_sym] || @document[blacklight_config.view.maps.coordinates_field.to_sym] %> + <% geojson_features = serialize_geojson(@document) %> + <%= blacklight_map_tag('blacklight-show-map', + {data:{viewpoint: set_viewpoint(geojson_features), + initialzoom:blacklight_config.view.maps.show_initial_zoom, + singlemarkermode:false}}) %> + <%= javascript_tag "$('#blacklight-show-map').blacklight_leaflet_map(#{geojson_features});" %> + <% end %> +
    \ No newline at end of file diff --git a/app/views/catalog/map.html.erb b/app/views/catalog/map.html.erb new file mode 100644 index 0000000..931bf56 --- /dev/null +++ b/app/views/catalog/map.html.erb @@ -0,0 +1,8 @@ +
    +

    <%= t('blacklight.maps.title') %>

    +
    <%= t('blacklight.maps.leader') %>
    + <%= render 'search_results' %> +
    + +<%# have to put this at the end so it overrides 'catalog/search_results' %> +<% @page_title = t('blacklight.maps.title', :application_name => application_name) %> \ No newline at end of file diff --git a/blacklight-maps.gemspec b/blacklight-maps.gemspec index ccaac0e..2d06df0 100644 --- a/blacklight-maps.gemspec +++ b/blacklight-maps.gemspec @@ -22,11 +22,10 @@ Gem::Specification.new do |spec| spec.add_dependency "bootstrap-sass", "~> 3.0" spec.add_dependency "leaflet-rails" spec.add_dependency "leaflet-markercluster-rails" - spec.add_dependency "leaflet-sidebar-rails", "~> 0.0.2" spec.add_development_dependency "bundler", "~> 1.5" spec.add_development_dependency "rake" - spec.add_development_dependency "rspec-rails", "~> 2.9" + spec.add_development_dependency "rspec-rails", "~> 3.0" spec.add_development_dependency "jettywrapper" spec.add_development_dependency "engine_cart", "~> 0.4.0" spec.add_development_dependency "capybara" diff --git a/config/locales/blacklight-maps.en.yml b/config/locales/blacklight-maps.en.yml index 1a387cd..24ea821 100644 --- a/config/locales/blacklight-maps.en.yml +++ b/config/locales/blacklight-maps.en.yml @@ -1,5 +1,21 @@ en: blacklight: + + maps: + interactions: + bbox_search: 'View items that intersect with this bounding box' + placename_search: 'View items from this location' + item: 'item' + point_search: 'View items from this location' + search_ctrl_cue: 'Search for all items within the current map window' + title: 'Map' + leader: 'Click on a marker to search for items from that location.' + search: + filters: + coordinates: + bbox: 'Bounding Box' + point: 'Coordinates' view: - maps: "Map" \ No newline at end of file + maps: 'Map' + diff --git a/config/routes.rb b/config/routes.rb index 353666f..9e18c75 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,4 @@ -Blacklight::Maps::Engine.routes.draw do - +Rails.application.routes.draw do + get 'map', :to => 'catalog#map', :as => 'map' end diff --git a/docs/blacklight-maps_index-view.png b/docs/blacklight-maps_index-view.png new file mode 100755 index 0000000..673b455 Binary files /dev/null and b/docs/blacklight-maps_index-view.png differ diff --git a/docs/blacklight-maps_map-view.png b/docs/blacklight-maps_map-view.png new file mode 100755 index 0000000..ee13c1f Binary files /dev/null and b/docs/blacklight-maps_map-view.png differ diff --git a/docs/blacklight-maps_search-control.png b/docs/blacklight-maps_search-control.png new file mode 100755 index 0000000..76ce270 Binary files /dev/null and b/docs/blacklight-maps_search-control.png differ diff --git a/docs/blacklight-maps_show-view.png b/docs/blacklight-maps_show-view.png new file mode 100755 index 0000000..151e431 Binary files /dev/null and b/docs/blacklight-maps_show-view.png differ diff --git a/docs/map-sidebar.png b/docs/map-sidebar.png deleted file mode 100644 index ca14ac9..0000000 Binary files a/docs/map-sidebar.png and /dev/null differ diff --git a/docs/map-view.png b/docs/map-view.png deleted file mode 100644 index cfe8f4b..0000000 Binary files a/docs/map-view.png and /dev/null differ diff --git a/lib/blacklight/maps.rb b/lib/blacklight/maps.rb index a6a9163..0c41610 100644 --- a/lib/blacklight/maps.rb +++ b/lib/blacklight/maps.rb @@ -2,8 +2,24 @@ module Blacklight module Maps + require 'blacklight/maps/controller_override' + require 'blacklight/maps/render_constraints_override' require 'blacklight/maps/engine' require 'blacklight/maps/export' require 'blacklight/maps/geometry' + + def self.inject! + CatalogController.send(:include, BlacklightMaps::ControllerOverride) + CatalogController.send(:include, BlacklightMaps::RenderConstraintsOverride) + CatalogController.send(:helper, BlacklightMaps::RenderConstraintsOverride) unless + CatalogController.helpers.is_a?(BlacklightMaps::RenderConstraintsOverride) + + # inject into SearchHistory and SavedSearches so spatial queries display properly + SearchHistoryController.send(:helper, BlacklightMaps::RenderConstraintsOverride) unless + SearchHistoryController.helpers.is_a?(BlacklightMaps::RenderConstraintsOverride) + SavedSearchesController.send(:helper, BlacklightMaps::RenderConstraintsOverride) unless + SavedSearchesController.helpers.is_a?(BlacklightMaps::RenderConstraintsOverride) + end + end end diff --git a/lib/blacklight/maps/controller_override.rb b/lib/blacklight/maps/controller_override.rb new file mode 100644 index 0000000..fa131f2 --- /dev/null +++ b/lib/blacklight/maps/controller_override.rb @@ -0,0 +1,33 @@ +module BlacklightMaps + module ControllerOverride + extend ActiveSupport::Concern + included do + solr_search_params_logic << :add_spatial_search_to_solr + end + + def map + (@response, @document_list) = get_search_results + params[:view] = 'maps' + respond_to do |format| + format.html + end + end + + # add spatial search params to solr + def add_spatial_search_to_solr(solr_parameters, user_parameters) + if user_parameters[:spatial_search_type] && user_parameters[:coordinates] + solr_parameters[:fq] ||= [] + if user_parameters[:spatial_search_type] == 'bbox' + solr_parameters[:fq] << blacklight_config.view.maps.coordinates_field + ":" + user_parameters[:coordinates] + else + solr_parameters[:fq] << "{!geofilt sfield=#{blacklight_config.view.maps.coordinates_field}}" + solr_parameters[:pt] = user_parameters[:coordinates] + solr_parameters[:d] = blacklight_config.view.maps.spatial_query_dist + end + end + solr_parameters + end + + end + +end \ No newline at end of file diff --git a/lib/blacklight/maps/engine.rb b/lib/blacklight/maps/engine.rb index 0702d0c..322c108 100644 --- a/lib/blacklight/maps/engine.rb +++ b/lib/blacklight/maps/engine.rb @@ -1,25 +1,33 @@ require 'blacklight' require 'leaflet-rails' require 'leaflet-markercluster-rails' -require 'leaflet-sidebar-rails' module Blacklight module Maps class Engine < Rails::Engine # Set some default configurations - Blacklight::Configuration.default_values[:view].maps.type = 'bbox' - Blacklight::Configuration.default_values[:view].maps.bbox_field = 'place_bbox' - Blacklight::Configuration.default_values[:view].maps.placename_coord_field = 'placename_coords' + Blacklight::Configuration.default_values[:view].maps.geojson_field = "geojson" + Blacklight::Configuration.default_values[:view].maps.placename_property = "placename" + Blacklight::Configuration.default_values[:view].maps.coordinates_field = "coordinates" + Blacklight::Configuration.default_values[:view].maps.search_mode = "placename" # or 'coordinates' + Blacklight::Configuration.default_values[:view].maps.spatial_query_dist = 0.5 + Blacklight::Configuration.default_values[:view].maps.placename_field = "placename_field" + Blacklight::Configuration.default_values[:view].maps.coordinates_facet_field = "coordinates_facet_field" + Blacklight::Configuration.default_values[:view].maps.facet_mode = "geojson" # or 'coordinates' Blacklight::Configuration.default_values[:view].maps.tileurl = "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" Blacklight::Configuration.default_values[:view].maps.mapattribution = 'Map data © OpenStreetMap contributors, CC-BY-SA' - Blacklight::Configuration.default_values[:view].maps.maxzoom = 8 - Blacklight::Configuration.default_values[:view].maps.placename_coord_delimiter = '-|-' + Blacklight::Configuration.default_values[:view].maps.maxzoom = 18 + Blacklight::Configuration.default_values[:view].maps.show_initial_zoom = 5 # Add our helpers initializer 'blacklight-maps.helpers' do |app| ActionView::Base.send :include, BlacklightMapsHelper end + config.to_prepare do + Blacklight::Maps.inject! + end + # This makes our rake tasks visible. rake_tasks do Dir.chdir(File.expand_path(File.join(File.dirname(__FILE__), '..', '..'))) do diff --git a/lib/blacklight/maps/export.rb b/lib/blacklight/maps/export.rb index 5eecb0f..1d79013 100644 --- a/lib/blacklight/maps/export.rb +++ b/lib/blacklight/maps/export.rb @@ -9,12 +9,17 @@ class GeojsonExport include BlacklightMaps # controller is a Blacklight CatalogController object passed by a helper - # response_docs is an array of documents passed by a helper - def initialize(controller, response_docs) + # action is the controller action + # response_docs is passed by a helper, and is either: + # - index view, map view: an array of facet values + # - show view: the document object + def initialize(controller, action, response_docs) @controller = controller + @action = action @response_docs = response_docs end + # build the GeoJSON FeatureCollection def to_geojson geojson_docs = { type: 'FeatureCollection', features: build_geojson_features } @@ -27,85 +32,121 @@ def blacklight_maps_config @controller.blacklight_config.view.maps end - def type - blacklight_maps_config.type + def geojson_field + blacklight_maps_config.geojson_field end - def placename_coord_field - blacklight_maps_config.placename_coord_field + def coordinates_field + blacklight_maps_config.coordinates_field end - def placename_coord_delimiter - blacklight_maps_config.placename_coord_delimiter + def search_mode + blacklight_maps_config.search_mode end - def bbox_field - blacklight_maps_config.bbox_field + def facet_mode + blacklight_maps_config.facet_mode end - def build_geojson_features - case type - when 'placename_coord' - build_placename_coord_features - when 'bbox' - build_bbox_features - else - Rails.logger.error("Your Solr field type was not configured with a recognized type, '#{type}' is not yet supported") - end + def placename_property + blacklight_maps_config.placename_property end - # Builds the features structure for placename_coord type documents - def build_placename_coord_features + # build GeoJSON features array + # determine how to build GeoJSON feature based on config and controller#action + def build_geojson_features features = [] - @response_docs.each do |doc| - next if doc[placename_coord_field].nil? - doc[placename_coord_field].uniq.each do |loc| - values = loc.split(placename_coord_delimiter) - features.push( - build_point_feature(values[2], values[1], - name: values[0], - html: render_leaflet_sidebar_partial(doc))) - end + case @action + when "index", "map" + @response_docs.each do |geofacet| + if facet_mode == "coordinates" + features.push(build_feature_from_coords(geofacet.value, geofacet.hits)) + else + features.push(build_feature_from_geojson(geofacet.value, geofacet.hits)) + end + end + when "show" + doc = @response_docs + return unless doc[geojson_field] || doc[coordinates_field] + if doc[geojson_field] + doc[geojson_field].uniq.each do |loc| + features.push(build_feature_from_geojson(loc)) + end + elsif doc[coordinates_field] + doc[coordinates_field].uniq.each do |coords| + features.push(build_feature_from_coords(coords)) + end + end end features end - # Builds the features structure for bbox type documents - def build_bbox_features - features = [] - @response_docs.each do |doc| - next if doc[bbox_field].nil? - doc[bbox_field].uniq.each do |loc| - lnglat = Geometry::BoundingBox.from_lon_lat_string(loc).find_center - features.push( - build_point_feature(lnglat[0], lnglat[1], - html: render_leaflet_sidebar_partial(doc))) - end + # build blacklight-maps GeoJSON feature from GeoJSON-formatted data + # turn bboxes into points for index view so we don't get weird mix of boxes and markers + def build_feature_from_geojson(loc, hits = nil) + geojson_hash = JSON.parse(loc).deep_symbolize_keys + + if @action != "show" && geojson_hash[:bbox] + geojson_hash[:geometry][:coordinates] = Geometry::BoundingBox.new(geojson_hash[:bbox]).find_center + geojson_hash[:geometry][:type] = "Point" + geojson_hash.delete(:bbox) end - features + geojson_hash[:properties] ||= {} + geojson_hash[:properties][:hits] = hits.to_i if hits + geojson_hash[:properties][:popup] = render_leaflet_popup_content(geojson_hash, hits) + geojson_hash end - # Render to string the partial for each individual doc - def render_leaflet_sidebar_partial(doc) - @controller.render_to_string partial: 'catalog/index_maps', - locals: { document: SolrDocument.new(doc) } + # build blacklight-maps GeoJSON feature from coordinate data + # turn bboxes into points for index view so we don't get weird mix of boxes and markers + def build_feature_from_coords(coords, hits = nil) + geojson_hash = {type: "Feature", geometry: {}, properties: {}} + if coords.scan(/[\s]/).length == 3 # bbox + if @action != "show" + geojson_hash[:geometry][:type] = "Point" + geojson_hash[:geometry][:coordinates] = Geometry::BoundingBox.from_lon_lat_string(coords).find_center + else + coords_array = coords.split(' ').map { |v| v.to_f } + geojson_hash[:bbox] = coords_array + geojson_hash[:geometry][:type] = "Polygon" + geojson_hash[:geometry][:coordinates] = [[[coords_array[0],coords_array[1]], + [coords_array[2],coords_array[1]], + [coords_array[2],coords_array[3]], + [coords_array[0],coords_array[3]], + [coords_array[0],coords_array[1]]]] + end + elsif coords.match(/^[-]?[\d]*[\.]?[\d]*[ ,][-]?[\d]*[\.]?[\d]*$/) # point + geojson_hash[:geometry][:type] = "Point" + if coords.match(/,/) + coords_array = coords.split(',').reverse + else + coords_array = coords.split(' ') + end + geojson_hash[:geometry][:coordinates] = coords_array.map { |v| v.to_f } + else + Rails.logger.error("This coordinate format is not yet supported: '#{coords}'") + end + geojson_hash[:properties] = { popup: render_leaflet_popup_content(geojson_hash, hits) } if geojson_hash[:geometry][:coordinates] + geojson_hash[:properties][:hits] = hits.to_i if hits + geojson_hash end - # Build the individual feature which is added to the FeatureCollection. - # lng is the longitude of the feature - # lat is the latitude of the feature - # *args additional arguments can be passed to the feature, these arguments - # will be reflected in the 'properties' member. - # html: "html string to show up" must be passed for the sidebar to display - # list items - def build_point_feature(lng, lat, *args) - properties = args.extract_options! - feature = { type: 'Feature', - geometry: { - type: 'Point', - coordinates: [lng.to_f, lat.to_f] }, - properties: properties } - feature + # Render to string the partial for each individual doc. + # For placename searching, render catalog/map_placename_search partial, + # full geojson hash is passed to the partial for easier local customization + # For coordinate searches (or features with only coordinate data), + # render catalog/map_coordinate_search partial + def render_leaflet_popup_content(geojson_hash, hits=nil) + if search_mode == 'placename' && geojson_hash[:properties][placename_property.to_sym] + @controller.render_to_string partial: 'catalog/map_placename_search', + locals: { geojson_hash: geojson_hash, hits: hits } + else + @controller.render_to_string partial: 'catalog/map_spatial_search', + locals: { coordinates: geojson_hash[:bbox].presence || geojson_hash[:geometry][:coordinates], + hits: hits } + end end + end + end diff --git a/lib/blacklight/maps/render_constraints_override.rb b/lib/blacklight/maps/render_constraints_override.rb new file mode 100644 index 0000000..bf2ec47 --- /dev/null +++ b/lib/blacklight/maps/render_constraints_override.rb @@ -0,0 +1,62 @@ +# Meant to be applied on top of Blacklight view helpers, to over-ride +# certain methods from RenderConstraintsHelper (newish in BL), +# to affect constraints rendering +module BlacklightMaps + + module RenderConstraintsOverride + + # BlacklightMaps override: update to look for spatial query params + def has_search_parameters? + has_spatial_parameters? || super + end + + def has_spatial_parameters? + !params[:coordinates].blank? + end + + # BlacklightMaps override: check for coordinate parameters + def query_has_constraints?(localized_params = params) + has_search_parameters? || super + end + + # BlacklightMaps override: include render_spatial_query() in rendered constraints + def render_constraints(localized_params = params) + render_spatial_query(localized_params) + super + end + + # BlacklightMaps override: include render_search_to_s_coord() in rendered constraints + # Simpler textual version of constraints, used on Search History page. + def render_search_to_s(params) + render_search_to_s_coord(params) + super + end + + ## + # Render the search query constraint + def render_search_to_s_coord(params) + return "".html_safe if params[:coordinates].blank? + render_search_to_s_element(spatial_constraint_label(params), render_filter_value(params[:coordinates]) ) + end + + # Render the spatial query constraints + def render_spatial_query(localized_params = params) + # So simple don't need a view template, we can just do it here. + scope = localized_params.delete(:route_set) || self + return ''.html_safe if localized_params[:coordinates].blank? + + render_constraint_element(spatial_constraint_label(localized_params), + localized_params[:coordinates], + :classes => ['coordinates'], + :remove => scope.url_for(localized_params.merge(:coordinates=>nil, + :spatial_search_type=>nil, + :action=>'index'))) + end + + def spatial_constraint_label(params) + (params[:spatial_search_type] && params[:spatial_search_type] == 'bbox') ? + t('blacklight.search.filters.coordinates.bbox') : + t('blacklight.search.filters.coordinates.point') + end + + end + +end \ No newline at end of file diff --git a/lib/generators/blacklight_maps/templates/blacklight_maps.css.scss b/lib/generators/blacklight_maps/templates/blacklight_maps.css.scss index 5321b4b..9716761 100644 --- a/lib/generators/blacklight_maps/templates/blacklight_maps.css.scss +++ b/lib/generators/blacklight_maps/templates/blacklight_maps.css.scss @@ -1,3 +1,3 @@ /* -*= require blacklight_maps/default +*= require blacklight_maps/blacklight-maps */ \ No newline at end of file diff --git a/solr_conf/conf/schema.xml b/solr_conf/conf/schema.xml index 7eea251..aff9a3a 100644 --- a/solr_conf/conf/schema.xml +++ b/solr_conf/conf/schema.xml @@ -534,7 +534,10 @@ - + + + +