Skip to content
65 changes: 36 additions & 29 deletions GeolocationPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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()
Expand Down Expand Up @@ -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");
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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'])) {
Expand Down Expand Up @@ -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,
];
}
}
Expand Down Expand Up @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions config_form.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,10 @@
</div>
<div class="field">
<div class="two columns alpha">
<label for="cluster"><?php echo __('Enable marker clustering'); ?></label>
<label for="cluster"><?php echo __('Enable location clustering'); ?></label>
</div>
<div class="inputs five columns omega">
<p class="explanation"><?php echo __('Show close or overlapping markers as clusters.'); ?></p>
<p class="explanation"><?php echo __('Show close or overlapping locations as clusters.'); ?></p>
<?php echo $view->formCheckbox('cluster', true, ['checked' => (bool) get_option('geolocation_cluster')]); ?>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 9 additions & 5 deletions models/Api/Location.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
65 changes: 59 additions & 6 deletions models/Location.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
}
}

/**
Expand All @@ -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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions views/helpers/GeolocationMapBrowse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 6 additions & 2 deletions views/helpers/GeolocationMapOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading