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 Fig\Http\Message\StatusCodeInterface; 22use Fisharebest\Webtrees\Exceptions\IndividualAccessDeniedException; 23use Fisharebest\Webtrees\Exceptions\IndividualNotFoundException; 24use Fisharebest\Webtrees\Fact; 25use Fisharebest\Webtrees\Family; 26use Fisharebest\Webtrees\Functions\Functions; 27use Fisharebest\Webtrees\GedcomTag; 28use Fisharebest\Webtrees\I18N; 29use Fisharebest\Webtrees\Individual; 30use Fisharebest\Webtrees\Location; 31use Fisharebest\Webtrees\Menu; 32use Fisharebest\Webtrees\Services\ChartService; 33use Fisharebest\Webtrees\Tree; 34use Fisharebest\Webtrees\Webtrees; 35use Psr\Http\Message\ResponseInterface; 36use Psr\Http\Message\ServerRequestInterface; 37use function intdiv; 38 39/** 40 * Class PedigreeMapModule 41 */ 42class PedigreeMapModule extends AbstractModule implements ModuleChartInterface 43{ 44 use ModuleChartTrait; 45 46 // Defaults 47 public const DEFAULT_GENERATIONS = '4'; 48 49 // Limits 50 public const MAXIMUM_GENERATIONS = 10; 51 52 private const LINE_COLORS = [ 53 '#FF0000', 54 // Red 55 '#00FF00', 56 // Green 57 '#0000FF', 58 // Blue 59 '#FFB300', 60 // Gold 61 '#00FFFF', 62 // Cyan 63 '#FF00FF', 64 // Purple 65 '#7777FF', 66 // Light blue 67 '#80FF80' 68 // Light green 69 ]; 70 71 private static $map_providers; 72 private static $map_selections; 73 74 /** 75 * How should this module be identified in the control panel, etc.? 76 * 77 * @return string 78 */ 79 public function title(): string 80 { 81 /* I18N: Name of a module */ 82 return I18N::translate('Pedigree map'); 83 } 84 85 /** 86 * A sentence describing what this module does. 87 * 88 * @return string 89 */ 90 public function description(): string 91 { 92 /* I18N: Description of the “OSM” module */ 93 return I18N::translate('Show the birthplace of ancestors on a map.'); 94 } 95 96 /** 97 * CSS class for the URL. 98 * 99 * @return string 100 */ 101 public function chartMenuClass(): string 102 { 103 return 'menu-chart-pedigreemap'; 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|null 112 */ 113 public function chartBoxMenu(Individual $individual): ?Menu 114 { 115 return $this->chartMenu($individual); 116 } 117 118 /** 119 * The title for a specific instance of this chart. 120 * 121 * @param Individual $individual 122 * 123 * @return string 124 */ 125 public function chartTitle(Individual $individual): string 126 { 127 /* I18N: %s is an individual’s name */ 128 return I18N::translate('Pedigree map of %s', $individual->fullName()); 129 } 130 131 /** 132 * The URL for this chart. 133 * 134 * @param Individual $individual 135 * @param string[] $parameters 136 * 137 * @return string 138 */ 139 public function chartUrl(Individual $individual, array $parameters = []): string 140 { 141 return route('module', [ 142 'module' => $this->name(), 143 'action' => 'PedigreeMap', 144 'xref' => $individual->xref(), 145 'ged' => $individual->tree()->name(), 146 ] + $parameters); 147 } 148 149 /** 150 * @param ServerRequestInterface $request 151 * @param Tree $tree 152 * @param ChartService $chart_service 153 * 154 * @return ResponseInterface 155 */ 156 public function getMapDataAction(ServerRequestInterface $request, Tree $tree, ChartService $chart_service): ResponseInterface 157 { 158 $xref = $request->getQueryParams()['reference']; 159 $indi = Individual::getInstance($xref, $tree); 160 $color_count = count(self::LINE_COLORS); 161 162 $facts = $this->getPedigreeMapFacts($request, $tree, $chart_service); 163 164 $geojson = [ 165 'type' => 'FeatureCollection', 166 'features' => [], 167 ]; 168 169 $sosa_points = []; 170 171 foreach ($facts as $id => $fact) { 172 $location = new Location($fact->place()->gedcomName()); 173 174 // Use the co-ordinates from the fact (if they exist). 175 $latitude = $fact->latitude(); 176 $longitude = $fact->longitude(); 177 178 // Use the co-ordinates from the location otherwise. 179 if ($latitude === 0.0 && $longitude === 0.0) { 180 $latitude = $location->latitude(); 181 $longitude = $location->longitude(); 182 } 183 184 $icon = ['color' => 'Gold', 'name' => 'bullseye ']; 185 if ($latitude !== 0.0 || $longitude !== 0.0) { 186 $polyline = null; 187 $color = self::LINE_COLORS[log($id, 2) % $color_count]; 188 $icon['color'] = $color; //make icon color the same as the line 189 $sosa_points[$id] = [$latitude, $longitude]; 190 $sosa_parent = intdiv($id, 2); 191 if (array_key_exists($sosa_parent, $sosa_points)) { 192 // Would like to use a GeometryCollection to hold LineStrings 193 // rather than generate polylines but the MarkerCluster library 194 // doesn't seem to like them 195 $polyline = [ 196 'points' => [ 197 $sosa_points[$sosa_parent], 198 [$latitude, $longitude], 199 ], 200 'options' => [ 201 'color' => $color, 202 ], 203 ]; 204 } 205 $geojson['features'][] = [ 206 'type' => 'Feature', 207 'id' => $id, 208 'valid' => true, 209 'geometry' => [ 210 'type' => 'Point', 211 'coordinates' => [$longitude, $latitude], 212 ], 213 'properties' => [ 214 'polyline' => $polyline, 215 'icon' => $icon, 216 'tooltip' => strip_tags($fact->place()->fullName()), 217 'summary' => view('modules/pedigree-map/events', $this->summaryData($indi, $fact, $id)), 218 'zoom' => $location->zoom() ?: 2, 219 ], 220 ]; 221 } 222 } 223 224 $code = empty($facts) ? StatusCodeInterface::STATUS_NO_CONTENT : StatusCodeInterface::STATUS_OK; 225 226 return response($geojson, $code); 227 } 228 229 /** 230 * @param ServerRequestInterface $request 231 * @param Tree $tree 232 * @param ChartService $chart_service 233 * 234 * @return array 235 */ 236 private function getPedigreeMapFacts(ServerRequestInterface $request, Tree $tree, ChartService $chart_service): array 237 { 238 $xref = $request->getQueryParams()['reference']; 239 $individual = Individual::getInstance($xref, $tree); 240 $generations = (int) $request->getQueryParams()['generations']; 241 $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations); 242 $facts = []; 243 foreach ($ancestors as $sosa => $person) { 244 if ($person->canShow()) { 245 $birth = $person->facts(['BIRT'])->first(); 246 if ($birth instanceof Fact && $birth->place()->gedcomName() !== '') { 247 $facts[$sosa] = $birth; 248 } 249 } 250 } 251 252 return $facts; 253 } 254 255 /** 256 * @param Individual $individual 257 * @param Fact $fact 258 * @param int $sosa 259 * 260 * @return array 261 */ 262 private function summaryData(Individual $individual, Fact $fact, int $sosa): array 263 { 264 $record = $fact->record(); 265 $name = ''; 266 $url = ''; 267 $tag = $fact->label(); 268 $addbirthtag = false; 269 270 if ($record instanceof Family) { 271 // Marriage 272 $spouse = $record->spouse($individual); 273 if ($spouse) { 274 $url = $spouse->url(); 275 $name = $spouse->fullName(); 276 } 277 } elseif ($record !== $individual) { 278 // Birth of a child 279 $url = $record->url(); 280 $name = $record->fullName(); 281 $tag = GedcomTag::getLabel('_BIRT_CHIL', $record); 282 } 283 284 if ($sosa > 1) { 285 $addbirthtag = true; 286 $tag = ucfirst($this->getSosaName($sosa)); 287 } 288 289 return [ 290 'tag' => $tag, 291 'url' => $url, 292 'name' => $name, 293 'value' => $fact->value(), 294 'date' => $fact->date()->display(true), 295 'place' => $fact->place(), 296 'addtag' => $addbirthtag, 297 ]; 298 } 299 300 /** 301 * @param ServerRequestInterface $request 302 * 303 * @return ResponseInterface 304 */ 305 public function getProviderStylesAction(ServerRequestInterface $request): ResponseInterface 306 { 307 $styles = $this->getMapProviderData($request); 308 309 return response($styles); 310 } 311 312 /** 313 * @param ServerRequestInterface $request 314 * 315 * @return array|null 316 */ 317 private function getMapProviderData(ServerRequestInterface $request): ?array 318 { 319 if (self::$map_providers === null) { 320 $providersFile = Webtrees::MODULES_DIR . 'openstreetmap/providers/providers.xml'; 321 self::$map_selections = [ 322 'provider' => $this->getPreference('provider', 'openstreetmap'), 323 'style' => $this->getPreference('provider_style', 'mapnik'), 324 ]; 325 326 try { 327 $xml = simplexml_load_string(file_get_contents($providersFile)); 328 // need to convert xml structure into arrays & strings 329 foreach ($xml as $provider) { 330 $style_keys = array_map( 331 static function (string $item): string { 332 return preg_replace('/[^a-z\d]/i', '', strtolower($item)); 333 }, 334 (array) $provider->styles 335 ); 336 337 $key = preg_replace('/[^a-z\d]/i', '', strtolower((string) $provider->name)); 338 339 self::$map_providers[$key] = [ 340 'name' => (string) $provider->name, 341 'styles' => array_combine($style_keys, (array) $provider->styles), 342 ]; 343 } 344 } catch (Exception $ex) { 345 // Default provider is OpenStreetMap 346 self::$map_selections = [ 347 'provider' => 'openstreetmap', 348 'style' => 'mapnik', 349 ]; 350 self::$map_providers = [ 351 'openstreetmap' => [ 352 'name' => 'OpenStreetMap', 353 'styles' => ['mapnik' => 'Mapnik'], 354 ], 355 ]; 356 } 357 } 358 359 //Ugly!!! 360 switch ($request->get('action')) { 361 case 'BaseData': 362 $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']]; 363 $payload = [ 364 'selectedProvIndex' => self::$map_selections['provider'], 365 'selectedProvName' => self::$map_providers[self::$map_selections['provider']]['name'], 366 'selectedStyleName' => $varName, 367 ]; 368 break; 369 case 'ProviderStyles': 370 $provider = $request->get('provider', 'openstreetmap'); 371 $payload = self::$map_providers[$provider]['styles']; 372 break; 373 case 'AdminConfig': 374 $providers = []; 375 foreach (self::$map_providers as $key => $provider) { 376 $providers[$key] = $provider['name']; 377 } 378 $payload = [ 379 'providers' => $providers, 380 'selectedProv' => self::$map_selections['provider'], 381 'styles' => self::$map_providers[self::$map_selections['provider']]['styles'], 382 'selectedStyle' => self::$map_selections['style'], 383 ]; 384 break; 385 default: 386 $payload = null; 387 } 388 389 return $payload; 390 } 391 392 /** 393 * @param ServerRequestInterface $request 394 * @param Tree $tree 395 * 396 * @return object 397 */ 398 public function getPedigreeMapAction(ServerRequestInterface $request, Tree $tree) 399 { 400 $xref = $request->getQueryParams()['xref']; 401 $individual = Individual::getInstance($xref, $tree); 402 $generations = $request->getQueryParams()['generations'] ?? self::DEFAULT_GENERATIONS; 403 404 if ($individual === null) { 405 throw new IndividualNotFoundException(); 406 } 407 408 if (!$individual->canShow()) { 409 throw new IndividualAccessDeniedException(); 410 } 411 412 return $this->viewResponse('modules/pedigree-map/page', [ 413 'module_name' => $this->name(), 414 /* I18N: %s is an individual’s name */ 415 'title' => I18N::translate('Pedigree map of %s', $individual->fullName()), 416 'tree' => $tree, 417 'individual' => $individual, 418 'generations' => $generations, 419 'maxgenerations' => self::MAXIMUM_GENERATIONS, 420 'map' => view( 421 'modules/pedigree-map/chart', 422 [ 423 'module' => $this->name(), 424 'ref' => $individual->xref(), 425 'type' => 'pedigree', 426 'generations' => $generations, 427 ] 428 ), 429 ]); 430 } 431 432 /** 433 * builds and returns sosa relationship name in the active language 434 * 435 * @param int $sosa Sosa number 436 * 437 * @return string 438 */ 439 private function getSosaName(int $sosa): string 440 { 441 $path = ''; 442 443 while ($sosa > 1) { 444 if ($sosa % 2 === 1) { 445 $path = 'mot' . $path; 446 } else { 447 $path = 'fat' . $path; 448 } 449 $sosa = intdiv($sosa, 2); 450 } 451 452 return Functions::getRelationshipNameFromPath($path); 453 } 454} 455