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