diff --git a/GeolocationPlugin.php b/GeolocationPlugin.php index 649440f..bf15bec 100644 --- a/GeolocationPlugin.php +++ b/GeolocationPlugin.php @@ -58,6 +58,7 @@ public function hookInstall() `zoom_level` INT NOT NULL , `address` TEXT NOT NULL , `label` VARCHAR( 255 ) NOT NULL DEFAULT '' , + `geometry_json` TEXT NOT NULL , INDEX (`item_id`)) ENGINE = InnoDB"; $db->query($sql); @@ -71,6 +72,7 @@ public function hookInstall() set_option('geolocation_basemap', self::DEFAULT_BASEMAP); set_option('geolocation_geocoder', self::DEFAULT_GEOCODER); set_option('geolocation_item_map_enable', '1'); + set_option('geolocation_auto_fit_browse', '1'); } public function hookUninstall() @@ -163,7 +165,13 @@ public function hookUpgrade($args) } if (version_compare($args['old_version'], '4.0', '<')) { $db = get_db(); - $db->query("ALTER TABLE `$db->Location` ADD COLUMN `label` VARCHAR(255) NOT NULL DEFAULT '' AFTER `address`, DROP COLUMN `map_type`"); + // Three steps: add nullable, back-fill from existing lat/lng, + // then tighten to NOT NULL. MySQL rejects adding a NOT NULL + // column to a non-empty table without a default, and a + // placeholder default would corrupt the existing coordinate data. + $db->query("ALTER TABLE `$db->Location` ADD COLUMN `label` VARCHAR(255) NOT NULL DEFAULT '' AFTER `address`, DROP COLUMN `map_type`, ADD COLUMN `geometry_json` TEXT NULL"); + $db->query("UPDATE `$db->Location` SET `geometry_json` = CONCAT('{\"type\":\"Point\",\"coordinates\":[', `longitude`, ',', `latitude`, ']}')"); + $db->query("ALTER TABLE `$db->Location` MODIFY COLUMN `geometry_json` TEXT NOT NULL"); } } @@ -263,13 +271,9 @@ private function _head() $version = Zend_Registry::get('plugin_loader')->getPlugin('Geolocation')->getIniVersion(); queue_css_file('leaflet/leaflet', null, null, 'javascripts', $version); queue_css_file('leaflet-draw/leaflet.draw', null, null, 'javascripts', $version); - queue_css_file('geolocation-marker', null, null, 'css', $version); - queue_js_file(['leaflet/leaflet', 'leaflet/leaflet-providers', 'leaflet-draw/leaflet.draw', 'map'], 'javascripts', [], $version); - - if (get_option('geolocation_cluster')) { - queue_css_file(['MarkerCluster', 'MarkerCluster.Default'], null, null, 'javascripts/leaflet-markercluster', $version); - queue_js_file('leaflet-markercluster/leaflet.markercluster', 'javascripts', [], $version); - } + queue_css_file('geolocation-map', null, null, 'css', $version); + queue_css_file(['MarkerCluster', 'MarkerCluster.Default'], null, null, 'javascripts/leaflet-markercluster', $version); + queue_js_file(['leaflet/leaflet', 'leaflet/leaflet-providers', 'leaflet-draw/leaflet.draw', 'leaflet-deflate/L.Deflate', 'leaflet-markercluster/leaflet.markercluster', 'map'], 'javascripts', [], $version); } public function hookAfterSaveItem($args) @@ -281,7 +285,7 @@ public function hookAfterSaveItem($args) $item = $args['record']; // geolocation_form_shown is a sentinel set by input-partial.php. Its // presence means the map form was rendered, so an empty geolocation_locations - // value means all markers were deleted, not that the form was absent. + // value means all locations were deleted, not that the form was absent. if (!isset($post['geolocation_form_shown'])) { return; } @@ -295,7 +299,7 @@ public function hookAfterSaveItem($args) } foreach (json_decode($post['geolocation_locations'] ?? '[]', true) as $entry) { - if (!is_numeric($entry['latitude'] ?? null) || !is_numeric($entry['longitude'] ?? null)) { + if (empty($entry['geometry_json'])) { continue; } $id = !empty($entry['id']) ? (int) $entry['id'] : null; @@ -648,9 +652,9 @@ public function geolocationShortcode($args) $options = []; if (isset($args['fit'])) { - $options['fitMarkers'] = $booleanFilter->filter($args['fit']); + $options['fitLocations'] = $booleanFilter->filter($args['fit']); } else { - $options['fitMarkers'] = '1'; + $options['fitLocations'] = '1'; } if (isset($args['type'])) { @@ -700,27 +704,29 @@ protected function _mapForm($item, $label = '', $view = null) $existingLocations = []; if (isset($_POST['geolocation_form_shown'])) { foreach (json_decode($_POST['geolocation_locations'] ?? '[]', true) as $entry) { - if (!is_numeric($entry['latitude'] ?? null) || !is_numeric($entry['longitude'] ?? null)) { + if (empty($entry['geometry_json'])) { continue; } $existingLocations[] = [ - 'id' => !empty($entry['id']) ? (int) $entry['id'] : null, - 'latitude' => (float) $entry['latitude'], - 'longitude' => (float) $entry['longitude'], - 'zoom_level' => (int) ($entry['zoom_level'] ?? 0), - 'address' => $entry['address'] ?? '', - 'label' => $entry['label'] ?? '', + 'id' => !empty($entry['id']) ? (int) $entry['id'] : null, + 'latitude' => (float) ($entry['latitude'] ?? 0), + 'longitude' => (float) ($entry['longitude'] ?? 0), + 'zoom_level' => (int) ($entry['zoom_level'] ?? 0), + 'address' => $entry['address'] ?? '', + 'label' => $entry['label'] ?? '', + 'geometry_json' => $entry['geometry_json'], ]; } } elseif ($item && $item->id) { foreach ($this->_db->getTable('Location')->findBy(['item_id' => $item->id]) as $loc) { $existingLocations[] = [ - 'id' => $loc->id, - 'latitude' => $loc->latitude, - 'longitude' => $loc->longitude, - 'zoom_level' => $loc->zoom_level, - 'address' => $loc->address, - 'label' => $loc->label, + 'id' => $loc->id, + 'latitude' => $loc->latitude, + 'longitude' => $loc->longitude, + 'zoom_level' => $loc->zoom_level, + 'address' => $loc->address, + 'label' => $loc->label, + 'geometry_json' => $loc->geometry_json, ]; } } @@ -803,7 +809,7 @@ public function filterStaticSiteExportOmekaShortcodeCallbacks($callbacks) // @see GeolocationPlugin::geolocationShortcode() $callbacks['geolocation'] = function ($args, $frontMatter, $job) { $frontMatter['css'][] = 'vendor/leaflet/leaflet.css'; - $frontMatter['css'][] = 'vendor/omeka-geolocation/geolocation-marker.css'; + $frontMatter['css'][] = 'vendor/omeka-geolocation/geolocation-map.css'; $frontMatter['js'][] = 'vendor/jquery/jquery.js'; $frontMatter['js'][] = 'vendor/leaflet/leaflet.js'; $frontMatter['js'][] = 'vendor/omeka-geolocation/geolocation-locations.js'; @@ -825,7 +831,7 @@ public function hookStaticSiteExportSiteExportPost($args) 'title' => __('Map'), 'css' => [ 'vendor/leaflet/leaflet.css', - 'vendor/omeka-geolocation/geolocation-marker.css', + 'vendor/omeka-geolocation/geolocation-map.css', ], 'js' => [ 'vendor/jquery/jquery.js', @@ -869,7 +875,7 @@ public function hookStaticSiteExportItemBundle($args) } $frontMatterPage['css'][] = 'vendor/leaflet/leaflet.css'; - $frontMatterPage['css'][] = 'vendor/omeka-geolocation/geolocation-marker.css'; + $frontMatterPage['css'][] = 'vendor/omeka-geolocation/geolocation-map.css'; $frontMatterPage['js'][] = 'vendor/jquery/jquery.js'; $frontMatterPage['js'][] = 'vendor/leaflet/leaflet.js'; $frontMatterPage['js'][] = 'vendor/omeka-geolocation/geolocation-locations.js'; @@ -916,7 +922,7 @@ public function hookExhibitBuilderStaticSiteExportExhibitPageBlock($args) $attachments = $exhibitPageBlock->getAttachments(); $frontMatterExhibitPage['css'][] = 'vendor/leaflet/leaflet.css'; - $frontMatterExhibitPage['css'][] = 'vendor/omeka-geolocation/geolocation-marker.css'; + $frontMatterExhibitPage['css'][] = 'vendor/omeka-geolocation/geolocation-map.css'; $frontMatterExhibitPage['js'][] = 'vendor/jquery/jquery.js'; $frontMatterExhibitPage['js'][] = 'vendor/leaflet/leaflet.js'; $frontMatterExhibitPage['js'][] = 'vendor/omeka-geolocation/geolocation-locations.js'; @@ -945,6 +951,7 @@ private function _locationToStaticSiteExportArray(Location $location, Item $item { $file = $item->getFile(); return [ + 'geometry_json' => $location->geometry_json, 'latitude' => $location->latitude, 'longitude' => $location->longitude, 'zoomLevel' => $location->zoom_level, diff --git a/config_form.php b/config_form.php index 0aa02ac..7a34919 100644 --- a/config_form.php +++ b/config_form.php @@ -258,10 +258,10 @@
- +
-

+

formCheckbox('cluster', true, ['checked' => (bool) get_option('geolocation_cluster')]); ?>
diff --git a/libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-locations.js b/libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-locations.js index 02e2349..0d5b479 100644 --- a/libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-locations.js +++ b/libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-locations.js @@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', function(event) { const featureGroup = L.featureGroup(); // Get the locations data and add the locations to the map. + let lastGeometry = null; locationsData.forEach((locationData) => { const popupDiv = document.createElement('div'); const popupHeading = document.createElement('h2'); @@ -27,14 +28,14 @@ document.addEventListener('DOMContentLoaded', function(event) { popupDiv.appendChild(popupImg); } - const marker = L.marker([locationData.latitude, locationData.longitude]); - marker.bindPopup(popupDiv); - marker.addTo(featureGroup); + lastGeometry = JSON.parse(locationData.geometry_json); + const layer = L.geoJSON(lastGeometry); + layer.bindPopup(popupDiv); + layer.addTo(featureGroup); }); map.fitBounds(featureGroup.getBounds()); - if (locationsData.length === 1) { - // Set the zoom level if there is only one location. + if (locationsData.length === 1 && lastGeometry.type === 'Point') { map.setZoom(locationsData[0].zoomLevel ?? 15); } diff --git a/libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-marker.css b/libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-map.css similarity index 60% rename from libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-marker.css rename to libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-map.css index c413414..b50a3f8 100644 --- a/libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-marker.css +++ b/libraries/Geolocation/StaticSiteExport/omeka-geolocation/geolocation-map.css @@ -28,16 +28,33 @@ div#geolocation { padding:0; } -.geolocation_balloon { +.leaflet-popup-content-wrapper:has(.geolocation-popup) { + overflow: hidden; + padding: 0; +} + +.leaflet-popup-content:has(.geolocation-popup) { + margin: 0; +} + +.geolocation-popup { width: 200px; + padding: 0 20px 13px; } -.geolocation_balloon img { - max-width: 100%; + +.geolocation-popup-header { + margin: 0 -20px 13px; + padding: 8px 20px; + background: #e3e3e3; + font-weight: bold; +} + +.geolocation-popup a { + border-bottom: none; } -.geolocation_balloon_title { - font-weight:bold; - font-size:18px; - margin-bottom:0px; + +.geolocation-popup img { + max-width: 100%; } img.leaflet-tile, diff --git a/models/Api/Location.php b/models/Api/Location.php index cf6273c..b517b7a 100644 --- a/models/Api/Location.php +++ b/models/Api/Location.php @@ -22,6 +22,7 @@ public function getRepresentation(Omeka_Record_AbstractRecord $record) $representation = [ 'id' => $record->id, 'url' => $this->getResourceUrl("/geolocations/{$record->id}"), + 'geometry_json' => $record->geometry_json, 'latitude' => $record->latitude, 'longitude' => $record->longitude, 'zoom_level' => $record->zoom_level, @@ -63,11 +64,14 @@ public function setPutData(Omeka_Record_AbstractRecord $record, $data) private function _applyLocationFields(Omeka_Record_AbstractRecord $record, $data) { - if (isset($data->latitude)) { - $record->latitude = $data->latitude; - } - if (isset($data->longitude)) { - $record->longitude = $data->longitude; + if (isset($data->geometry_json)) { + $record->geometry_json = $data->geometry_json; + } elseif (isset($data->latitude) && isset($data->longitude)) { + // Fallback for pre-4.0 API clients that post lat/lng without geometry_json + $record->geometry_json = json_encode([ + 'type' => 'Point', + 'coordinates' => [(float) $data->longitude, (float) $data->latitude], + ]); } if (isset($data->zoom_level)) { $record->zoom_level = $data->zoom_level; diff --git a/models/Location.php b/models/Location.php index 9477393..219284f 100644 --- a/models/Location.php +++ b/models/Location.php @@ -12,6 +12,7 @@ class Location extends Omeka_Record_AbstractRecord implements Zend_Acl_Resource_ public $zoom_level; public $address; public $label; + public $geometry_json; /** * Executes before the record is saved. @@ -24,6 +25,26 @@ protected function beforeSave($args) if (is_null($this->label)) { $this->label = ''; } + // latitude and longitude are kept in sync with geometry_json so that + // geographic radius search (hookItemsBrowseSql) works for all location + // types without spatial SQL functions. For shapes, we use the bounding + // box center as a representative point. + $geometry = json_decode($this->geometry_json, true); + if ($geometry) { + if ($geometry['type'] === 'Point') { + $this->longitude = $geometry['coordinates'][0]; + $this->latitude = $geometry['coordinates'][1]; + } else { + // Polygon coordinates[0] is the outer boundary; LineString coordinates is the points array directly + $coords = $geometry['type'] === 'Polygon' + ? $geometry['coordinates'][0] + : $geometry['coordinates']; + $lngs = array_column($coords, 0); + $lats = array_column($coords, 1); + $this->longitude = (min($lngs) + max($lngs)) / 2; + $this->latitude = (min($lats) + max($lats)) / 2; + } + } } /** @@ -38,15 +59,47 @@ protected function _validate() if (!$this->getTable('Item')->exists($this->item_id)) { $this->addError('item_id', __('Location requires a valid item ID.')); } - if (!is_numeric($this->latitude)) { - $this->addError('latitude', __('Location requires a latitude.')); + if (!$this->_isValidGeometry(json_decode($this->geometry_json, true))) { + $this->addError('geometry_json', __('Location requires a valid geometry.')); + } + } + + /** Validates that $geometry is a well-formed GeoJSON geometry object. */ + private function _isValidGeometry($geometry) + { + if (!is_array($geometry)) { + return false; + } + $type = $geometry['type'] ?? ''; + $coords = $geometry['coordinates'] ?? null; + if (!is_array($coords)) { + return false; + } + if ($type === 'Point') { + return $this->_isValidPosition($coords); } - if (!is_numeric($this->longitude)) { - $this->addError('longitude', __('Location requires a longitude.')); + if ($type === 'LineString') { + return count($coords) >= 2 && $this->_areValidPositions($coords); } - if (!is_numeric($this->zoom_level)) { - $this->addError('zoom_level', __('Location requires a zoom level.')); + if ($type === 'Polygon') { + return isset($coords[0]) && count($coords[0]) >= 4 && $this->_areValidPositions($coords[0]); + } + return false; // unrecognized type + } + + private function _isValidPosition($pos) + { + return is_array($pos) && count($pos) >= 2 && is_numeric($pos[0]) && is_numeric($pos[1]); + } + + private function _areValidPositions($positions) + { + foreach ($positions as $pos) { + if (!$this->_isValidPosition($pos)) { + return false; + } } + return true; } /** diff --git a/views/helpers/GeolocationMapBrowse.php b/views/helpers/GeolocationMapBrowse.php index ccc16a5..41455ec 100644 --- a/views/helpers/GeolocationMapBrowse.php +++ b/views/helpers/GeolocationMapBrowse.php @@ -22,8 +22,8 @@ public function geolocationMapBrowse($divId = 'map', $options = [], $attrs = [], $options['uri'] = url('geolocation/map/browse-json'); } - if (!array_key_exists('fitMarkers', $options)) { - $options['fitMarkers'] = (bool) get_option('geolocation_auto_fit_browse'); + if (!array_key_exists('fitLocations', $options)) { + $options['fitLocations'] = (bool) get_option('geolocation_auto_fit_browse'); } $class = 'map geolocation-map'; diff --git a/views/helpers/GeolocationMapOptions.php b/views/helpers/GeolocationMapOptions.php index c30605e..5195023 100644 --- a/views/helpers/GeolocationMapOptions.php +++ b/views/helpers/GeolocationMapOptions.php @@ -28,8 +28,12 @@ public function geolocationMapOptions($options = []) $options['custom_map'] = json_decode((string) get_option('geolocation_custom_map'), true); $options['strings'] = [ - 'fitAllMarkers' => __('Fit all markers'), - 'label' => __('Label'), + 'fitAllLocations' => __('Fit all locations'), + 'label' => __('Label'), + 'editLocations' => __('Edit locations'), + 'noLocationsToEdit' => __('No locations to edit'), + 'deleteLocations' => __('Delete locations'), + 'noLocationsToDelete' => __('No locations to delete'), ]; return js_escape($options); diff --git a/views/helpers/GeolocationMapSingle.php b/views/helpers/GeolocationMapSingle.php index 8058f97..ff5ecab 100644 --- a/views/helpers/GeolocationMapSingle.php +++ b/views/helpers/GeolocationMapSingle.php @@ -12,7 +12,7 @@ public function geolocationMapSingle($item = null, $width = '200px', $height = ' } // For single-location items this sets the initial zoom correctly. - // For multi-location items fitMarkers() overrides the center after all points are added. + // For multi-location items fitLocations() overrides the center after all points are added. $center = [ 'latitude' => $locations[0]->latitude, 'longitude' => $locations[0]->longitude, @@ -22,14 +22,12 @@ public function geolocationMapSingle($item = null, $width = '200px', $height = ' $points = []; foreach ($locations as $loc) { $point = [ - 'latitude' => $loc->latitude, - 'longitude' => $loc->longitude, - 'zoomLevel' => $loc->zoom_level, - 'label' => $loc->label, + 'geometry_json' => $loc->geometry_json, + 'label' => $loc->label, ]; if ($loc->label !== '') { - $point['markerHtml'] = '
' - . '
' . html_escape($loc->label) . '
' + $point['popupHtml'] = '
' + . '
' . html_escape($loc->label) . '
' . '
'; } $points[] = $point; @@ -37,7 +35,8 @@ public function geolocationMapSingle($item = null, $width = '200px', $height = ' $options = []; $options['basemap'] = get_option('geolocation_basemap'); - $options['points'] = $points; + $options['locations'] = $points; + $options['cluster'] = true; $options = $this->view->geolocationMapOptions($options); $center = js_escape($center); $varDivId = Inflector::variablize($divId); diff --git a/views/shared/css/geolocation-items-map.css b/views/shared/css/geolocation-items-map.css index 5997e3f..3b9de08 100644 --- a/views/shared/css/geolocation-items-map.css +++ b/views/shared/css/geolocation-items-map.css @@ -41,14 +41,6 @@ margin-top: 0 !important; } -/* The map for the items page needs a bit of styling on it */ -#address_balloon dt { - font-weight: bold; -} -#address_balloon { - width: 100px; -} - div.map-notification { display:block; border: 1px dotted #ccc; diff --git a/views/shared/css/geolocation-marker.css b/views/shared/css/geolocation-map.css similarity index 67% rename from views/shared/css/geolocation-marker.css rename to views/shared/css/geolocation-map.css index 1b05bb5..02260ba 100644 --- a/views/shared/css/geolocation-marker.css +++ b/views/shared/css/geolocation-map.css @@ -1,6 +1,6 @@ #omeka-map-form { width: 100%; - height: 300px; + height: 500px; clear: both; } #geolocation_address { @@ -28,16 +28,33 @@ div#geolocation { padding:0; } -.geolocation_balloon { +.leaflet-popup-content-wrapper:has(.geolocation-popup) { + overflow: hidden; + padding: 0; +} + +.leaflet-popup-content:has(.geolocation-popup) { + margin: 0; +} + +.geolocation-popup { width: 200px; + padding: 0 20px 13px; } -.geolocation_balloon img { - max-width: 100%; + +.geolocation-popup-header { + margin: 0 -20px 13px; + padding: 8px 20px; + background: #e3e3e3; + font-weight: bold; +} + +.geolocation-popup a { + border-bottom: none; } -.geolocation_balloon_title { - font-weight:bold; - font-size:18px; - margin-bottom:0px; + +.geolocation-popup img { + max-width: 100%; } .leaflet-control-fit-all a { diff --git a/views/shared/exhibit_layouts/geolocation-map/layout.php b/views/shared/exhibit_layouts/geolocation-map/layout.php index 85a0a7d..15be75f 100644 --- a/views/shared/exhibit_layouts/geolocation-map/layout.php +++ b/views/shared/exhibit_layouts/geolocation-map/layout.php @@ -10,6 +10,7 @@ foreach ($attachments as $attachment): $item = $attachment->getItem(); $file = $attachment->getFile(); + $title = metadata($item, 'display_title', ['no_escape' => true]); $titleLink = exhibit_builder_link_to_exhibit_item(null, [], $item); if ($file): @@ -20,14 +21,14 @@ $itemLocations = $locationTable->findBy(['item_id' => $item->id]); foreach ($itemLocations as $location): - $title = $titleLink . ($location->label ? ' — ' . html_escape($location->label) : ''); - $html = '
' - . '
' . $title . '
' + $headerText = $location->label ? html_escape($location->label) : html_escape($title); + $html = '
' + . '
' . $headerText . '
' . $body + . '
' . $titleLink . '
' . '
'; $locations[] = [ - 'lat' => $location->latitude, - 'lng' => $location->longitude, + 'geometry_json' => $location->geometry_json, 'html' => $html, ]; endforeach; @@ -43,13 +44,9 @@ var map_locations = ; for (var i = 0; i < map_locations.length; i++) { var locationData = map_locations[i]; - geolocation_map.addMarker( - [locationData.lat, locationData.lng], - {}, - locationData.html - ); + geolocation_map.addLayerFromGeometry(JSON.parse(locationData.geometry_json), {}, locationData.html); } - geolocation_map.fitMarkers(); + geolocation_map.fitLocations(); });
diff --git a/views/shared/javascripts/leaflet-deflate/L.Deflate.js b/views/shared/javascripts/leaflet-deflate/L.Deflate.js new file mode 100644 index 0000000..09782e0 --- /dev/null +++ b/views/shared/javascripts/leaflet-deflate/L.Deflate.js @@ -0,0 +1 @@ +"use strict";L.Layer.include({_originalRemove:L.Layer.prototype.remove,remove:function(){if(this.marker){this.marker.remove()}return this._originalRemove()}});L.Map.include({_originalRemoveLayer:L.Map.prototype.removeLayer,removeLayer:function(layer){if(layer.marker){layer.marker.remove()}return this._originalRemoveLayer(layer)}});L.Deflate=L.FeatureGroup.extend({options:{minSize:10,markerOptions:{},markerType:L.marker,greedyCollapse:true},initialize:function(options){L.Util.setOptions(this,options);this._layers=[];this._needsPrepping=[];this._featureLayer=this._getFeatureLayer(options)},_getFeatureLayer:function(){if(this.options.markerLayer){return this.options.markerLayer}return L.featureGroup(this.options)},_getBounds:function(path){if(path instanceof L.Circle){path.addTo(this._map);const bounds=path.getBounds();this._map.removeLayer(path);return bounds}return path.getBounds()},_isCollapsed:function(path,zoom){const bounds=path.computedBounds;const northEastPixels=this._map.project(bounds.getNorthEast(),zoom);const southWestPixels=this._map.project(bounds.getSouthWest(),zoom);const width=Math.abs(northEastPixels.x-southWestPixels.x);const height=Math.abs(southWestPixels.y-northEastPixels.y);if(this.options.greedyCollapse){return height 0) { - this.map.fitBounds(this.markerBounds, {padding: [25, 25]}); + fitLocations: function () { + if (!this.locationBounds.isValid()) { + return; + } + var bounds = this.locationBounds; + // fitBounds on a zero-area bounds (single point) zooms in too + // aggressively; panTo preserves the set zoom level. + if (bounds.getNorth() === bounds.getSouth() && bounds.getEast() === bounds.getWest()) { + this.map.panTo(bounds.getCenter()); + } else { + this.map.fitBounds(bounds, {padding: [25, 25]}); } }, + addShapeLayer: function (geojson, bindHtml) { + var layer = L.GeoJSON.geometryToLayer(geojson); + if (bindHtml) { + layer.bindPopup(bindHtml, {autoPanPadding: [50, 50]}); + } + this.deflateGroup.addLayer(layer); + this.locationBounds.extend(layer.getBounds()); + return layer; + }, + + addLayerFromGeometry: function (geometry, options, bindHtml) { + var layer; + if (geometry.type === 'Point') { + layer = this.addMarker([geometry.coordinates[1], geometry.coordinates[0]], options, bindHtml); + } else { + layer = this.addShapeLayer(geometry, bindHtml); + } + if (bindHtml) { + var srAlertsDiv = jQuery('#geolocation-sr-alerts'); + var title = options.title || ''; + var latlng = geometry.type === 'Point' + ? layer.getLatLng() + : layer.getBounds().getCenter(); + var parts = [title, srAlertsDiv.data('latString'), latlng.lat, + srAlertsDiv.data('longString'), latlng.lng]; + var srOpenedText = parts.concat(srAlertsDiv.data('openedString')).join(' '); + var srClosedText = parts.concat(srAlertsDiv.data('closedString')).join(' '); + // Leaflet popup events give no screen reader feedback; announce the + // layer title, coordinates, and open/close status. + layer.addEventListener('popupopen', function () { + srAlertsDiv.text(srOpenedText); + }); + layer.addEventListener('popupclose', function () { + srAlertsDiv.text(srClosedText); + }); + // Popup dimensions are calculated before images load; update on + // first open so the popup resizes correctly once the image loads. + layer.once('popupopen', function (event) { + var popup = event.popup; + jQuery(popup.getElement()).find('img').one('load', function () { + popup.update(); + }); + }); + } + return layer; + }, + initMap: function () { var customMap = this.options.custom_map; @@ -81,7 +95,8 @@ OmekaMap.prototype = { } this.map = L.map(this.mapDivId).setView([this.center.latitude, this.center.longitude], this.center.zoomLevel); - this.markerBounds = L.latLngBounds(); + this.locationBounds = L.latLngBounds(); + this.markers = []; L.tileLayer.provider(this.options.basemap, this.options.basemapOptions).addTo(this.map); @@ -100,9 +115,17 @@ OmekaMap.prototype = { this.clusterGroup = L.markerClusterGroup({ showCoverageOnHover: false }); - this.map.addLayer(this.clusterGroup); } + // markerLayer routes collapsed shapes into the cluster group so + // they cluster alongside point markers. + this.deflateGroup = L.deflate({ + minSize: 10, + markerLayer: this.clusterGroup, + greedyCollapse: false, + }); + this.map.addLayer(this.deflateGroup); + jQuery(this.map.getContainer()).trigger('o:geolocation:init_map', this); new OmekaFitControl({ position: 'topleft', omekaMap: this }).addTo(this.map); @@ -115,14 +138,14 @@ var OmekaFitControl = L.Control.extend({ var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-fit-all'); var link = L.DomUtil.create('a', '', container); link.href = '#'; - link.title = omekaMap.options.strings.fitAllMarkers; + link.title = omekaMap.options.strings.fitAllLocations; link.setAttribute('role', 'button'); - link.setAttribute('aria-label', omekaMap.options.strings.fitAllMarkers); + link.setAttribute('aria-label', omekaMap.options.strings.fitAllLocations); link.innerHTML = ''; L.DomEvent.on(link, 'click', function (e) { L.DomEvent.preventDefault(e); L.DomEvent.stopPropagation(e); - omekaMap.fitMarkers(); + omekaMap.fitLocations(); }); this._link = link; return container; @@ -142,8 +165,8 @@ function OmekaMapBrowse(mapDivId, center, options) { OmekaMapBrowse.prototype = { afterLoadItems: function () { - if (this.options.fitMarkers) { - this.fitMarkers(); + if (this.options.fitLocations) { + this.fitLocations(); } if (!this.options.list) { @@ -177,26 +200,31 @@ OmekaMapBrowse.prototype = { }, buildLayerFromLocation: function (locationData) { - this.addMarker( - [locationData.latitude, locationData.longitude], - {title: locationData.title, alt: locationData.title}, - this.buildMarkerContent(locationData) - ); + var geometry = JSON.parse(locationData.geometry_json); + var layer = this.addLayerFromGeometry(geometry, {title: locationData.title, alt: locationData.title}, this.buildLocationContent(locationData)); + // Shapes have no native title property; _geolocationTitle is read + // by buildListLinks to label them in the sidebar. Points are omitted + // because they carry their title via marker.options.title. + if (geometry.type !== 'Point') { + layer._geolocationTitle = locationData.title || ''; + } }, - buildMarkerContent: function (locationData) { - var balloon = jQuery('
'); - var titleLink = jQuery('').addClass('view-item').attr('href', locationData.itemUrl).text(locationData.title); - balloon.append(jQuery('
').append(titleLink)); + buildLocationContent: function (locationData) { + var popup = jQuery('
'); + var headerText = locationData.label || locationData.title; + popup.append(jQuery('
').text(headerText)); if (locationData.thumbnailUrl) { var img = jQuery('').attr({src: locationData.thumbnailUrl, alt: ''}); var thumbLink = jQuery('').addClass('view-item').attr('href', locationData.itemUrl).append(img); - balloon.append(jQuery('
').append(thumbLink)); + popup.append(jQuery('
').append(thumbLink)); } + var titleLink = jQuery('').addClass('view-item').attr('href', locationData.itemUrl).text(locationData.title); + popup.append(jQuery('
').append(titleLink)); if (locationData.snippet) { - balloon.append(jQuery('

').text(locationData.snippet)); + popup.append(jQuery('

').text(locationData.snippet)); } - return balloon[0]; + return popup[0]; }, buildListLinks: function (container) { @@ -204,25 +232,8 @@ OmekaMapBrowse.prototype = { var list = jQuery('

    '); list.appendTo(container); - // Loop through all the markers jQuery.each(this.markers, function (index, marker) { - var listElement = jQuery('
  • '); - - // Make an
    tag, give it a class for styling - var link = jQuery(''); - link.addClass('item-link'); - - // Links open up the markers on the map, clicking them doesn't actually go anywhere - link.attr('href', 'javascript:void(0);'); - link.attr('role', 'button'); - - // Each
  • starts with the title of the item - link.text(marker.options.title); - - // Clicking the link should take us to the map - link.bind('click', {}, function (event) { - link.toggleClass('current'); - + that._buildListItem(list, marker.options.title, function () { if (that.clusterGroup) { that.clusterGroup.zoomToShowLayer(marker, function () { marker.fire('click'); @@ -234,10 +245,29 @@ OmekaMapBrowse.prototype = { that.map.flyTo(marker.getLatLng()); } }); + }); - link.appendTo(listElement); - listElement.appendTo(list); + jQuery.each(this.deflateGroup.getLayers(), function (index, layer) { + that._buildListItem(list, layer._geolocationTitle, function () { + that.map.once('moveend', function () { + layer.openPopup(); + }); + that.map.fitBounds(layer.getBounds()); + }); }); + }, + + _buildListItem: function (list, title, onClick) { + var link = jQuery('') + .addClass('item-link') + .attr('href', 'javascript:void(0);') + .attr('role', 'button') + .text(title); + link.bind('click', {}, function () { + link.toggleClass('current'); + onClick(); + }); + jQuery('
  • ').append(link).appendTo(list); } }; @@ -245,12 +275,12 @@ function OmekaMapSingle(mapDivId, center, options) { var omekaMap = new OmekaMap(mapDivId, center, options); jQuery.extend(true, this, omekaMap); this.initMap(); - if (options.points && options.points.length) { - for (var i = 0; i < options.points.length; i++) { - var pt = options.points[i]; - this.addMarker([pt.latitude, pt.longitude], {title: pt.label, alt: pt.label}, pt.markerHtml); + if (options.locations && options.locations.length) { + for (var i = 0; i < options.locations.length; i++) { + var pt = options.locations[i]; + this.addLayerFromGeometry(JSON.parse(pt.geometry_json), {title: pt.label, alt: pt.label}, pt.popupHtml); } - this.fitMarkers(); + this.fitLocations(); } } @@ -266,13 +296,18 @@ function OmekaMapForm(mapDivId, center, options) { this.drawnItems = new L.FeatureGroup(); this.map.addLayer(this.drawnItems); + L.drawLocal.edit.toolbar.buttons.edit = options.strings.editLocations; + L.drawLocal.edit.toolbar.buttons.editDisabled = options.strings.noLocationsToEdit; + L.drawLocal.edit.toolbar.buttons.remove = options.strings.deleteLocations; + L.drawLocal.edit.toolbar.buttons.removeDisabled = options.strings.noLocationsToDelete; + var drawControl = new L.Control.Draw({ position: 'topleft', draw: { marker: true, - polyline: false, - polygon: false, - rectangle: false, + polyline: true, + polygon: true, + rectangle: true, circle: false, circlemarker: false, }, @@ -285,16 +320,23 @@ function OmekaMapForm(mapDivId, center, options) { this.map.addControl(drawControl); this.map.on(L.Draw.Event.CREATED, function (event) { - var latlng = event.layer.getLatLng(); - var marker = that.addLocation(latlng.lat, latlng.lng, that.map.getZoom(), null, '', ''); - marker.openPopup(); + if (event.layerType === 'marker') { + var latlng = event.layer.getLatLng(); + var marker = that.addLocation(latlng.lat, latlng.lng, that.map.getZoom(), null, '', ''); + marker.openPopup(); + } else { + that.addShape(JSON.stringify(event.layer.toGeoJSON().geometry)); + } }); this.map.on(L.Draw.Event.EDITED, function (event) { event.layers.eachLayer(function (layer) { - var latlng = layer.getLatLng(); - layer._locationData.latitude = latlng.lat; - layer._locationData.longitude = latlng.lng; + layer._locationData.geometry_json = JSON.stringify(layer.toGeoJSON().geometry); + if (layer instanceof L.Marker) { + var latlng = layer.getLatLng(); + layer._locationData.latitude = latlng.lat; + layer._locationData.longitude = latlng.lng; + } }); }); @@ -320,21 +362,50 @@ OmekaMapForm.prototype = { var marker = L.marker([lat, lng]); this.drawnItems.addLayer(marker); this.markers.push(marker); - this.markerBounds.extend([lat, lng]); + this.locationBounds.extend([lat, lng]); - marker._locationData = {id: id, latitude: lat, longitude: lng, zoom_level: zoom, address: address, label: label}; + marker._locationData = { + id: id, + latitude: lat, + longitude: lng, + zoom_level: zoom, + address: address, + label: label, + geometry_json: JSON.stringify({type: 'Point', coordinates: [lng, lat]}) + }; - var labelInput = jQuery('').val(label); - var popupContent = jQuery('
    ') - .append(jQuery('').text(this.options.strings.label + ': ').append(labelInput)); + this._bindLabelPopup(marker, label); - marker.bindPopup(popupContent[0], {autoPanPadding: [50, 50]}); + return marker; + }, + addShape: function (geometryJson, id, label) { + var layer = L.GeoJSON.geometryToLayer(JSON.parse(geometryJson)); + this.drawnItems.addLayer(layer); + this.locationBounds.extend(layer.getBounds()); + + var center = layer.getBounds().getCenter(); + layer._locationData = { + id: id || null, + latitude: center.lat, + longitude: center.lng, + zoom_level: 0, + address: '', + label: label || '', + geometry_json: geometryJson + }; + + this._bindLabelPopup(layer, label || ''); + }, + + _bindLabelPopup: function (layer, initialLabel) { + var labelInput = jQuery('').val(initialLabel); + var popupContent = jQuery('
    ') + .append(jQuery('').text(this.options.strings.label + ': ').append(labelInput)); + layer.bindPopup(popupContent[0], {autoPanPadding: [50, 50]}); labelInput.on('input', function () { - marker._locationData.label = jQuery(this).val(); + layer._locationData.label = jQuery(this).val(); }); - - return marker; }, getLocationCount: function () { diff --git a/views/shared/map/browse-json.php b/views/shared/map/browse-json.php index 174a43c..394fd9c 100644 --- a/views/shared/map/browse-json.php +++ b/views/shared/map/browse-json.php @@ -2,12 +2,11 @@ $output = []; foreach (loop('item') as $item): $itemLocations = $locations[$item->id] ?? []; - $rawTitle = metadata($item, 'display_title', ['no_escape' => true]); + $title = metadata($item, 'display_title', ['no_escape' => true]); $thumbnailUrl = metadata($item, 'has thumbnail') ? record_image_url($item, 'thumbnail') : ''; $snippet = (string) metadata($item, ['Dublin Core', 'Description'], ['snippet' => 150]); $itemUrl = record_url($item, 'show', true); foreach ($itemLocations as $location): - $displayTitle = $location->label ? "$rawTitle — {$location->label}" : $rawTitle; $output[] = [ 'id' => (int) $location->id, 'latitude' => (float) $location->latitude, @@ -15,7 +14,8 @@ 'zoom_level' => (int) $location->zoom_level, 'address' => $location->address, 'label' => $location->label, - 'title' => $displayTitle, + 'geometry_json' => $location->geometry_json, + 'title' => $title, 'thumbnailUrl' => $thumbnailUrl, 'snippet' => $snippet, 'itemId' => (int) $item->id, diff --git a/views/shared/map/input-partial.php b/views/shared/map/input-partial.php index b745b30..2140201 100644 --- a/views/shared/map/input-partial.php +++ b/views/shared/map/input-partial.php @@ -25,7 +25,11 @@