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