1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2019 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16declare(strict_types=1); 17 18namespace Fisharebest\Webtrees\Module; 19 20use Exception; 21use Fisharebest\Webtrees\Fact; 22use Fisharebest\Webtrees\Family; 23use Fisharebest\Webtrees\Functions\Functions; 24use Fisharebest\Webtrees\GedcomTag; 25use Fisharebest\Webtrees\I18N; 26use Fisharebest\Webtrees\Individual; 27use Fisharebest\Webtrees\Location; 28use Fisharebest\Webtrees\Webtrees; 29use stdClass; 30use Symfony\Component\HttpFoundation\JsonResponse; 31use Symfony\Component\HttpFoundation\Request; 32 33/** 34 * Class PlacesMapModule 35 */ 36class PlacesModule extends AbstractModule implements ModuleTabInterface 37{ 38 use ModuleTabTrait; 39 40 private static $map_providers = null; 41 private static $map_selections = null; 42 43 public const ICONS = [ 44 'BIRT' => ['color' => 'Crimson', 'name' => 'birthday-cake'], 45 'MARR' => ['color' => 'Green', 'name' => 'venus-mars'], 46 'DEAT' => ['color' => 'Black', 'name' => 'plus'], 47 'CENS' => ['color' => 'MediumBlue', 'name' => 'users'], 48 'RESI' => ['color' => 'MediumBlue', 'name' => 'home'], 49 'OCCU' => ['color' => 'MediumBlue', 'name' => 'briefcase'], 50 'GRAD' => ['color' => 'MediumBlue', 'name' => 'graduation-cap'], 51 'EDUC' => ['color' => 'MediumBlue', 'name' => 'university'], 52 ]; 53 54 public const DEFAULT_ICON = ['color' => 'Gold', 'name' => 'bullseye ']; 55 56 /** 57 * How should this module be labelled on tabs, menus, etc.? 58 * 59 * @return string 60 */ 61 public function title(): string 62 { 63 /* I18N: Name of a module */ 64 return I18N::translate('Places'); 65 } 66 67 /** 68 * A sentence describing what this module does. 69 * 70 * @return string 71 */ 72 public function description(): string 73 { 74 /* I18N: Description of the “OSM” module */ 75 return I18N::translate('Show the location of events on a map.'); 76 } 77 78 /** 79 * The default position for this tab. It can be changed in the control panel. 80 * 81 * @return int 82 */ 83 public function defaultTabOrder(): int 84 { 85 return 1; 86 } 87 88 /** {@inheritdoc} */ 89 public function hasTabContent(Individual $individual): bool 90 { 91 return true; 92 } 93 94 /** {@inheritdoc} */ 95 public function isGrayedOut(Individual $individual): bool 96 { 97 return false; 98 } 99 100 /** {@inheritdoc} */ 101 public function canLoadAjax(): bool 102 { 103 return true; 104 } 105 106 /** {@inheritdoc} */ 107 public function getTabContent(Individual $individual): string 108 { 109 return view('modules/places/tab', [ 110 'data' => $this->getMapData($individual), 111 ]); 112 } 113 114 /** 115 * @param Individual $indi 116 * 117 * @return stdClass 118 */ 119 private function getMapData(Individual $indi): stdClass 120 { 121 $facts = $this->getPersonalFacts($indi); 122 123 $geojson = [ 124 'type' => 'FeatureCollection', 125 'features' => [], 126 ]; 127 128 foreach ($facts as $id => $fact) { 129 $location = new Location($fact->place()->gedcomName()); 130 131 // Use the co-ordinates from the fact (if they exist). 132 $latitude = $fact->latitude(); 133 $longitude = $fact->longitude(); 134 135 // Use the co-ordinates from the location otherwise. 136 if ($latitude === 0.0 && $longitude === 0.0) { 137 $latitude = $location->latitude(); 138 $longitude = $location->longitude(); 139 } 140 141 $icon = self::ICONS[$fact->getTag()] ?? self::DEFAULT_ICON; 142 143 if ($latitude !== 0.0 || $longitude !== 0.0) { 144 $geojson['features'][] = [ 145 'type' => 'Feature', 146 'id' => $id, 147 'valid' => true, 148 'geometry' => [ 149 'type' => 'Point', 150 'coordinates' => [$latitude, $longitude], 151 ], 152 'properties' => [ 153 'polyline' => null, 154 'icon' => $icon, 155 'tooltip' => strip_tags($fact->place()->fullName()), 156 'summary' => view('modules/places/event-sidebar', $this->summaryData($indi, $fact)), 157 'zoom' => $location->zoom(), 158 ], 159 ]; 160 } 161 } 162 163 return (object) $geojson; 164 } 165 166 /** 167 * @param Individual $individual 168 * @param Fact $fact 169 * 170 * @return mixed[] 171 */ 172 private function summaryData(Individual $individual, Fact $fact): array 173 { 174 $record = $fact->record(); 175 $name = ''; 176 $url = ''; 177 $tag = $fact->label(); 178 179 if ($record instanceof Family) { 180 // Marriage 181 $spouse = $record->getSpouse($individual); 182 if ($spouse instanceof Individual) { 183 $url = $spouse->url(); 184 $name = $spouse->getFullName(); 185 } 186 } elseif ($record !== $individual) { 187 // Birth of a child 188 $url = $record->url(); 189 $name = $record->getFullName(); 190 $tag = GedcomTag::getLabel('_BIRT_CHIL', $record); 191 } 192 193 return [ 194 'tag' => $tag, 195 'url' => $url, 196 'name' => $name, 197 'value' => $fact->value(), 198 'date' => $fact->date()->display(true), 199 'place' => $fact->place(), 200 'addtag' => false, 201 ]; 202 } 203 204 /** 205 * @param Individual $individual 206 * 207 * @return array 208 * @throws Exception 209 */ 210 private function getPersonalFacts(Individual $individual): array 211 { 212 $facts = $individual->facts(); 213 foreach ($individual->getSpouseFamilies() as $family) { 214 $facts = array_merge($facts, $family->facts()); 215 // Add birth of children from this family to the facts array 216 foreach ($family->getChildren() as $child) { 217 $childsBirth = $child->getFirstFact('BIRT'); 218 if ($childsBirth && $childsBirth->place()->gedcomName() !== '') { 219 $facts[] = $childsBirth; 220 } 221 } 222 } 223 224 Functions::sortFacts($facts); 225 226 $useable_facts = array_filter( 227 $facts, 228 function (Fact $item): bool { 229 return $item->place()->gedcomName() !== ''; 230 } 231 ); 232 233 return array_values($useable_facts); 234 } 235 236 /** 237 * @param Request $request 238 * 239 * @return JsonResponse 240 */ 241 public function getProviderStylesAction(Request $request): JsonResponse 242 { 243 $styles = $this->getMapProviderData($request); 244 245 return new JsonResponse($styles); 246 } 247 248 /** 249 * @param Request $request 250 * 251 * @return array|null 252 */ 253 private function getMapProviderData(Request $request) 254 { 255 if (self::$map_providers === null) { 256 $providersFile = WT_ROOT . Webtrees::MODULES_PATH . 'openstreetmap/providers/providers.xml'; 257 self::$map_selections = [ 258 'provider' => $this->getPreference('provider', 'openstreetmap'), 259 'style' => $this->getPreference('provider_style', 'mapnik'), 260 ]; 261 262 try { 263 $xml = simplexml_load_file($providersFile); 264 // need to convert xml structure into arrays & strings 265 foreach ($xml as $provider) { 266 $style_keys = array_map( 267 function (string $item): string { 268 return preg_replace('/[^a-z\d]/i', '', strtolower($item)); 269 }, 270 (array) $provider->styles 271 ); 272 273 $key = preg_replace('/[^a-z\d]/i', '', strtolower((string) $provider->name)); 274 275 self::$map_providers[$key] = [ 276 'name' => (string) $provider->name, 277 'styles' => array_combine($style_keys, (array) $provider->styles), 278 ]; 279 } 280 } catch (Exception $ex) { 281 // Default provider is OpenStreetMap 282 self::$map_selections = [ 283 'provider' => 'openstreetmap', 284 'style' => 'mapnik', 285 ]; 286 self::$map_providers = [ 287 'openstreetmap' => [ 288 'name' => 'OpenStreetMap', 289 'styles' => ['mapnik' => 'Mapnik'], 290 ], 291 ]; 292 }; 293 } 294 295 //Ugly!!! 296 switch ($request->get('action')) { 297 case 'BaseData': 298 $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']]; 299 $payload = [ 300 'selectedProvIndex' => self::$map_selections['provider'], 301 'selectedProvName' => self::$map_providers[self::$map_selections['provider']]['name'], 302 'selectedStyleName' => $varName, 303 ]; 304 break; 305 case 'ProviderStyles': 306 $provider = $request->get('provider', 'openstreetmap'); 307 $payload = self::$map_providers[$provider]['styles']; 308 break; 309 case 'AdminConfig': 310 $providers = []; 311 foreach (self::$map_providers as $key => $provider) { 312 $providers[$key] = $provider['name']; 313 } 314 $payload = [ 315 'providers' => $providers, 316 'selectedProv' => self::$map_selections['provider'], 317 'styles' => self::$map_providers[self::$map_selections['provider']]['styles'], 318 'selectedStyle' => self::$map_selections['style'], 319 ]; 320 break; 321 default: 322 $payload = null; 323 } 324 325 return $payload; 326 } 327} 328