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