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