1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2018 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\Auth; 22use Fisharebest\Webtrees\Database; 23use Fisharebest\Webtrees\DebugBar; 24use Fisharebest\Webtrees\Exceptions\IndividualAccessDeniedException; 25use Fisharebest\Webtrees\Exceptions\IndividualNotFoundException; 26use Fisharebest\Webtrees\Fact; 27use Fisharebest\Webtrees\FactLocation; 28use Fisharebest\Webtrees\I18N; 29use Fisharebest\Webtrees\Individual; 30use Fisharebest\Webtrees\Log; 31use Fisharebest\Webtrees\Menu; 32use Fisharebest\Webtrees\Place; 33use Fisharebest\Webtrees\Tree; 34use Symfony\Component\HttpFoundation\JsonResponse; 35use Symfony\Component\HttpFoundation\Request; 36 37/** 38 * Class PedigreeMapModule 39 */ 40class PedigreeMapModule extends AbstractModule implements ModuleChartInterface 41{ 42 const OSM_MIN_ZOOM = 2; 43 const LINE_COLORS = [ 44 '#FF0000', 45 // Red 46 '#00FF00', 47 // Green 48 '#0000FF', 49 // Blue 50 '#FFB300', 51 // Gold 52 '#00FFFF', 53 // Cyan 54 '#FF00FF', 55 // Purple 56 '#7777FF', 57 // Light blue 58 '#80FF80' 59 // Light green 60 ]; 61 62 private static $map_providers = null; 63 private static $map_selections = null; 64 65 /** {@inheritdoc} */ 66 public function getTitle() 67 { 68 return /* I18N: Name of a module */ 69 I18N::translate('Pedigree map'); 70 } 71 72 /** {@inheritdoc} */ 73 public function getDescription() 74 { 75 return /* I18N: Description of the “OSM” module */ 76 I18N::translate('Show the birthplace of ancestors on a map.'); 77 } 78 79 /** {@inheritdoc} */ 80 public function defaultAccessLevel() 81 { 82 return Auth::PRIV_PRIVATE; 83 } 84 85 /** 86 * Return a menu item for this chart. 87 * 88 * @param Individual $individual 89 * 90 * @return Menu 91 */ 92 public function getChartMenu(Individual $individual) 93 { 94 return new Menu( 95 I18N::translate('Pedigree map'), 96 route('module', [ 97 'module' => $this->getName(), 98 'action' => 'PedigreeMap', 99 'xref' => $individual->getXref(), 100 ]), 101 'menu-chart-pedigreemap', 102 ['rel' => 'nofollow'] 103 ); 104 } 105 106 /** 107 * Return a menu item for this chart - for use in individual boxes. 108 * 109 * @param Individual $individual 110 * 111 * @return Menu 112 */ 113 public function getBoxChartMenu(Individual $individual) 114 { 115 return $this->getChartMenu($individual); 116 } 117 118 /** 119 * @param string $type 120 * 121 * @return array 122 */ 123 public function assets($type = 'user') 124 { 125 $dir = WT_MODULES_DIR . $this->getName(); 126 if ($type === 'admin') { 127 return [ 128 'css' => [ 129 $dir . '/assets/css/osm-module.css', 130 ], 131 'js' => [ 132 $dir . '/assets/js/osm-admin.js', 133 ], 134 ]; 135 } else { 136 return [ 137 'css' => [ 138 $dir . '/assets/css/osm-module.css', 139 ], 140 'js' => [ 141 $dir . '/assets/js/osm-module.js', 142 ], 143 ]; 144 } 145 } 146 147 /** 148 * @param Request $request 149 * 150 * @return JsonResponse 151 */ 152 public function getBaseDataAction(Request $request): JsonResponse 153 { 154 $provider = $this->getMapProviderData($request); 155 $style = $provider['selectedStyleName'] = '' ? '' : '.' . $provider['selectedStyleName']; 156 157 switch ($provider['selectedProvIndex']) { 158 case 'mapbox': 159 $providerOptions = [ 160 'id' => $this->getPreference('mapbox_id'), 161 'accessToken' => $this->getPreference('mapbox_token'), 162 ]; 163 break; 164 case 'here': 165 $providerOptions = [ 166 'app_id' => $this->getPreference('here_appid'), 167 'app_code' => $this->getPreference('here_appcode'), 168 ]; 169 break; 170 default: 171 $providerOptions = []; 172 }; 173 174 $options = [ 175 'minZoom' => self::OSM_MIN_ZOOM, 176 'providerName' => $provider['selectedProvName'] . $style, 177 'providerOptions' => $providerOptions, 178 'animate' => $this->getPreference('map_animate', 0), 179 'I18N' => [ 180 'zoomInTitle' => I18N::translate('Zoom in'), 181 'zoomOutTitle' => I18N::translate('Zoom out'), 182 'reset' => I18N::translate('Reset to initial map state'), 183 'noData' => I18N::translate('No mappable items'), 184 'error' => I18N::translate('An unknown error occurred'), 185 ], 186 ]; 187 188 return new JsonResponse($options); 189 } 190 191 /** 192 * @param Request $request 193 * 194 * @return JsonResponse 195 * @throws Exception 196 */ 197 public function getMapDataAction(Request $request): JsonResponse 198 { 199 $xref = $request->get('reference'); 200 $tree = $request->attributes->get('tree'); 201 $indi = Individual::getInstance($xref, $tree); 202 $color_count = count(self::LINE_COLORS); 203 204 $facts = $this->getPedigreeMapFacts($request); 205 206 $geojson = [ 207 'type' => 'FeatureCollection', 208 'features' => [], 209 ]; 210 if (empty($facts)) { 211 $code = 204; 212 } else { 213 $code = 200; 214 foreach ($facts as $id => $fact) { 215 $event = new FactLocation($fact, $indi); 216 $icon = $event->getIconDetails(); 217 if ($event->knownLatLon()) { 218 $polyline = null; 219 $color = self::LINE_COLORS[log($id, 2) % $color_count]; 220 $icon['color'] = $color; //make icon color the same as the line 221 $sosa_points[$id] = $event->getLatLonJSArray(); 222 $sosa_parent = (int)floor($id / 2); 223 if (array_key_exists($sosa_parent, $sosa_points)) { 224 // Would like to use a GeometryCollection to hold LineStrings 225 // rather than generate polylines but the MarkerCluster library 226 // doesn't seem to like them 227 $polyline = [ 228 'points' => [ 229 $sosa_points[$sosa_parent], 230 $event->getLatLonJSArray(), 231 ], 232 'options' => [ 233 'color' => $color, 234 ], 235 ]; 236 } 237 $geojson['features'][] = [ 238 'type' => 'Feature', 239 'id' => $id, 240 'valid' => true, 241 'geometry' => [ 242 'type' => 'Point', 243 'coordinates' => $event->getGeoJsonCoords(), 244 ], 245 'properties' => [ 246 'polyline' => $polyline, 247 'icon' => $icon, 248 'tooltip' => $event->toolTip(), 249 'summary' => view( 250 'modules/openstreetmap/event-sidebar', 251 $event->shortSummary('pedigree', $id) 252 ), 253 'zoom' => (int)$event->getZoom(), 254 ], 255 ]; 256 } 257 } 258 } 259 260 return new JsonResponse($geojson, $code); 261 } 262 263 /** 264 * @param Request $request 265 * 266 * @return array 267 * @throws Exception 268 */ 269 private function getPedigreeMapFacts(Request $request) 270 { 271 $xref = $request->get('reference'); 272 $tree = $request->attributes->get('tree'); 273 $individual = Individual::getInstance($xref, $tree); 274 $generations = (int)$request->get( 275 'generations', 276 $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS') 277 ); 278 $ancestors = $this->sosaStradonitzAncestors($individual, $generations); 279 $facts = []; 280 foreach ($ancestors as $sosa => $person) { 281 if ($person !== null && $person->canShow()) { 282 /** @var Fact $birth */ 283 $birth = $person->getFirstFact('BIRT'); 284 if ($birth && !$birth->getPlace()->isEmpty()) { 285 $facts[$sosa] = $birth; 286 } 287 } 288 } 289 290 return $facts; 291 } 292 293 /** 294 * @param Request $request 295 * 296 * @return JsonResponse 297 */ 298 public function getProviderStylesAction(Request $request): JsonResponse 299 { 300 $styles = $this->getMapProviderData($request); 301 302 return new JsonResponse($styles); 303 } 304 305 /** 306 * @param Request $request 307 * 308 * @return array|null 309 */ 310 private function getMapProviderData(Request $request) 311 { 312 if (self::$map_providers === null) { 313 $providersFile = WT_ROOT . WT_MODULES_DIR . $this->getName() . '/providers/providers.xml'; 314 self::$map_selections = [ 315 'provider' => $this->getPreference('provider', 'openstreetmap'), 316 'style' => $this->getPreference('provider_style', 'mapnik'), 317 ]; 318 319 try { 320 $xml = simplexml_load_file($providersFile); 321 // need to convert xml structure into arrays & strings 322 foreach ($xml as $provider) { 323 $style_keys = array_map( 324 function ($item) { 325 return preg_replace('/[^a-z\d]/i', '', strtolower($item)); 326 }, 327 (array)$provider->styles 328 ); 329 330 $key = preg_replace('/[^a-z\d]/i', '', strtolower((string)$provider->name)); 331 332 self::$map_providers[$key] = [ 333 'name' => (string)$provider->name, 334 'styles' => array_combine($style_keys, (array)$provider->styles), 335 ]; 336 } 337 } catch (Exception $ex) { 338 // Default provider is OpenStreetMap 339 self::$map_selections = [ 340 'provider' => 'openstreetmap', 341 'style' => 'mapnik', 342 ]; 343 self::$map_providers = [ 344 'openstreetmap' => [ 345 'name' => 'OpenStreetMap', 346 'styles' => ['mapnik' => 'Mapnik'], 347 ], 348 ]; 349 }; 350 } 351 352 //Ugly!!! 353 switch ($request->get('action')) { 354 case 'BaseData': 355 $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']]; 356 $payload = [ 357 'selectedProvIndex' => self::$map_selections['provider'], 358 'selectedProvName' => self::$map_providers[self::$map_selections['provider']]['name'], 359 'selectedStyleName' => $varName, 360 ]; 361 break; 362 case 'ProviderStyles': 363 $provider = $request->get('provider', 'openstreetmap'); 364 $payload = self::$map_providers[$provider]['styles']; 365 break; 366 case 'AdminConfig': 367 $providers = []; 368 foreach (self::$map_providers as $key => $provider) { 369 $providers[$key] = $provider['name']; 370 } 371 $payload = [ 372 'providers' => $providers, 373 'selectedProv' => self::$map_selections['provider'], 374 'styles' => self::$map_providers[self::$map_selections['provider']]['styles'], 375 'selectedStyle' => self::$map_selections['style'], 376 ]; 377 break; 378 default: 379 $payload = null; 380 } 381 382 return $payload; 383 } 384 385 /** 386 * @param Request $request 387 * 388 * @return object 389 * @throws Exception 390 */ 391 public function getPedigreeMapAction(Request $request) 392 { 393 /** @var Tree $tree */ 394 $tree = $request->attributes->get('tree'); 395 $xref = $request->get('xref'); 396 $individual = Individual::getInstance($xref, $tree); 397 $maxgenerations = $tree->getPreference('MAX_PEDIGREE_GENERATIONS'); 398 $generations = $request->get('generations', $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS')); 399 400 if ($individual === null) { 401 throw new IndividualNotFoundException; 402 } elseif (!$individual->canShow()) { 403 throw new IndividualAccessDeniedException; 404 } 405 406 return (object)[ 407 'name' => 'modules/openstreetmap/pedigreemap', 408 'data' => [ 409 'assets' => $this->assets(), 410 'module' => $this->getName(), 411 'title' => /* I18N: %s is an individual’s name */ 412 I18N::translate('Pedigree map of %s', $individual->getFullName()), 413 'tree' => $tree, 414 'individual' => $individual, 415 'generations' => $generations, 416 'maxgenerations' => $maxgenerations, 417 'map' => view( 418 'modules/openstreetmap/map', 419 [ 420 'assets' => $this->assets(), 421 'module' => $this->getName(), 422 'ref' => $individual->getXref(), 423 'type' => 'pedigree', 424 'generations' => $generations, 425 ] 426 ), 427 ], 428 ]; 429 } 430 431 // @TODO shift the following function to somewhere more appropriate during restructure 432 433 /** 434 * Copied from AbstractChartController.php 435 * 436 * Find the ancestors of an individual, and generate an array indexed by 437 * Sosa-Stradonitz number. 438 * 439 * @param Individual $individual Start with this individual 440 * @param int $generations Fetch this number of generations 441 * 442 * @return Individual[] 443 */ 444 private function sosaStradonitzAncestors(Individual $individual, int $generations): array 445 { 446 /** @var Individual[] $ancestors */ 447 $ancestors = [ 448 1 => $individual, 449 ]; 450 451 for ($i = 1, $max = 2 ** ($generations - 1); $i < $max; $i++) { 452 $ancestors[$i * 2] = null; 453 $ancestors[$i * 2 + 1] = null; 454 455 $individual = $ancestors[$i]; 456 457 if ($individual !== null) { 458 $family = $individual->getPrimaryChildFamily(); 459 if ($family !== null) { 460 if ($family->getHusband() !== null) { 461 $ancestors[$i * 2] = $family->getHusband(); 462 } 463 if ($family->getWife() !== null) { 464 $ancestors[$i * 2 + 1] = $family->getWife(); 465 } 466 } 467 } 468 } 469 470 return $ancestors; 471 } 472} 473