1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2022 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Module; 21 22use Fisharebest\Webtrees\Auth; 23use Fisharebest\Webtrees\Family; 24use Fisharebest\Webtrees\I18N; 25use Fisharebest\Webtrees\Individual; 26use Fisharebest\Webtrees\Location; 27use Fisharebest\Webtrees\Place; 28use Fisharebest\Webtrees\PlaceLocation; 29use Fisharebest\Webtrees\Registry; 30use Fisharebest\Webtrees\Services\LeafletJsService; 31use Fisharebest\Webtrees\Services\ModuleService; 32use Fisharebest\Webtrees\Services\SearchService; 33use Fisharebest\Webtrees\Tree; 34use Fisharebest\Webtrees\Validator; 35use Illuminate\Database\Capsule\Manager as DB; 36use Illuminate\Database\Query\Builder; 37use Illuminate\Database\Query\JoinClause; 38use Psr\Http\Message\ResponseInterface; 39use Psr\Http\Message\ServerRequestInterface; 40use Psr\Http\Server\RequestHandlerInterface; 41 42use function array_chunk; 43use function array_pop; 44use function array_reverse; 45use function ceil; 46use function count; 47use function redirect; 48use function route; 49use function view; 50 51/** 52 * Class IndividualListModule 53 */ 54class PlaceHierarchyListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface 55{ 56 use ModuleListTrait; 57 58 protected const ROUTE_URL = '/tree/{tree}/place-list'; 59 60 /** @var int The default access level for this module. It can be changed in the control panel. */ 61 protected int $access_level = Auth::PRIV_USER; 62 63 private LeafletJsService $leaflet_js_service; 64 65 private ModuleService $module_service; 66 67 private SearchService $search_service; 68 69 /** 70 * PlaceHierarchy constructor. 71 * 72 * @param LeafletJsService $leaflet_js_service 73 * @param ModuleService $module_service 74 * @param SearchService $search_service 75 */ 76 public function __construct(LeafletJsService $leaflet_js_service, ModuleService $module_service, SearchService $search_service) 77 { 78 $this->leaflet_js_service = $leaflet_js_service; 79 $this->module_service = $module_service; 80 $this->search_service = $search_service; 81 } 82 83 /** 84 * Initialization. 85 * 86 * @return void 87 */ 88 public function boot(): void 89 { 90 Registry::routeFactory()->routeMap() 91 ->get(static::class, static::ROUTE_URL, $this); 92 } 93 94 /** 95 * How should this module be identified in the control panel, etc.? 96 * 97 * @return string 98 */ 99 public function title(): string 100 { 101 /* I18N: Name of a module/list */ 102 return I18N::translate('Place hierarchy'); 103 } 104 105 /** 106 * A sentence describing what this module does. 107 * 108 * @return string 109 */ 110 public function description(): string 111 { 112 /* I18N: Description of the “Place hierarchy” module */ 113 return I18N::translate('The place hierarchy.'); 114 } 115 116 /** 117 * CSS class for the URL. 118 * 119 * @return string 120 */ 121 public function listMenuClass(): string 122 { 123 return 'menu-list-plac'; 124 } 125 126 /** 127 * @return array<string> 128 */ 129 public function listUrlAttributes(): array 130 { 131 return []; 132 } 133 134 /** 135 * @param Tree $tree 136 * 137 * @return bool 138 */ 139 public function listIsEmpty(Tree $tree): bool 140 { 141 return !DB::table('places') 142 ->where('p_file', '=', $tree->id()) 143 ->exists(); 144 } 145 146 /** 147 * Handle URLs generated by older versions of webtrees 148 * 149 * @param ServerRequestInterface $request 150 * 151 * @return ResponseInterface 152 */ 153 public function getListAction(ServerRequestInterface $request): ResponseInterface 154 { 155 $tree = Validator::attributes($request)->tree(); 156 157 return redirect($this->listUrl($tree, $request->getQueryParams())); 158 } 159 160 /** 161 * @param Tree $tree 162 * @param array<bool|int|string|array<string>|null> $parameters 163 * 164 * @return string 165 */ 166 public function listUrl(Tree $tree, array $parameters = []): string 167 { 168 $parameters['tree'] = $tree->name(); 169 170 return route(static::class, $parameters); 171 } 172 173 /** 174 * @param ServerRequestInterface $request 175 * 176 * @return ResponseInterface 177 */ 178 public function handle(ServerRequestInterface $request): ResponseInterface 179 { 180 $tree = Validator::attributes($request)->tree(); 181 $user = Validator::attributes($request)->user(); 182 183 Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user); 184 185 $action2 = $request->getQueryParams()['action2'] ?? 'hierarchy'; 186 $place_id = (int) ($request->getQueryParams()['place_id'] ?? 0); 187 $place = Place::find($place_id, $tree); 188 189 // Request for a non-existent place? 190 if ($place_id !== $place->id()) { 191 return redirect($place->url()); 192 } 193 194 $map_providers = $this->module_service->findByInterface(ModuleMapProviderInterface::class); 195 196 $content = ''; 197 $showmap = $map_providers->isNotEmpty(); 198 $data = null; 199 200 if ($showmap) { 201 $content .= view('modules/place-hierarchy/map', [ 202 'data' => $this->mapData($tree, $place), 203 'leaflet_config' => $this->leaflet_js_service->config(), 204 ]); 205 } 206 207 switch ($action2) { 208 case 'list': 209 default: 210 $alt_link = I18N::translate('Show place hierarchy'); 211 $alt_url = $this->listUrl($tree, ['action2' => 'hierarchy', 'place_id' => $place_id]); 212 $content .= view('modules/place-hierarchy/list', ['columns' => $this->getList($tree)]); 213 break; 214 case 'hierarchy': 215 case 'hierarchy-e': 216 $alt_link = I18N::translate('Show all places in a list'); 217 $alt_url = $this->listUrl($tree, ['action2' => 'list', 'place_id' => 0]); 218 $data = $this->getHierarchy($place); 219 $content .= ($data === null || $showmap) ? '' : view('place-hierarchy', $data); 220 if ($data === null || $action2 === 'hierarchy-e') { 221 $content .= view('modules/place-hierarchy/events', [ 222 'indilist' => $this->search_service->searchIndividualsInPlace($place), 223 'famlist' => $this->search_service->searchFamiliesInPlace($place), 224 'tree' => $place->tree(), 225 ]); 226 } 227 } 228 229 if ($data !== null && $action2 !== 'hierarchy-e' && $place->gedcomName() !== '') { 230 $events_link = $this->listUrl($tree, ['action2' => 'hierarchy-e', 'place_id' => $place_id]); 231 } else { 232 $events_link = ''; 233 } 234 235 $breadcrumbs = $this->breadcrumbs($place); 236 237 return $this->viewResponse('modules/place-hierarchy/page', [ 238 'alt_link' => $alt_link, 239 'alt_url' => $alt_url, 240 'breadcrumbs' => $breadcrumbs['breadcrumbs'], 241 'content' => $content, 242 'current' => $breadcrumbs['current'], 243 'events_link' => $events_link, 244 'place' => $place, 245 'title' => I18N::translate('Place hierarchy'), 246 'tree' => $tree, 247 'world_url' => $this->listUrl($tree), 248 ]); 249 } 250 251 /** 252 * @param Tree $tree 253 * @param Place $placeObj 254 * 255 * @return array<mixed> 256 */ 257 protected function mapData(Tree $tree, Place $placeObj): array 258 { 259 $places = $placeObj->getChildPlaces(); 260 $features = []; 261 $sidebar = ''; 262 $show_link = true; 263 264 if ($places === []) { 265 $places[] = $placeObj; 266 $show_link = false; 267 } 268 269 foreach ($places as $id => $place) { 270 $location = new PlaceLocation($place->gedcomName()); 271 272 if ($location->latitude() === null || $location->longitude() === null) { 273 $sidebar_class = 'unmapped'; 274 } else { 275 $sidebar_class = 'mapped'; 276 $features[] = [ 277 'type' => 'Feature', 278 'id' => $id, 279 'geometry' => [ 280 'type' => 'Point', 281 'coordinates' => [$location->longitude(), $location->latitude()], 282 ], 283 'properties' => [ 284 'tooltip' => $place->gedcomName(), 285 'popup' => view('modules/place-hierarchy/popup', [ 286 'showlink' => $show_link, 287 'place' => $place, 288 'latitude' => $location->latitude(), 289 'longitude' => $location->longitude(), 290 ]), 291 ], 292 ]; 293 } 294 295 $stats = [ 296 Family::RECORD_TYPE => $this->familyPlaceLinks($place)->count(), 297 Individual::RECORD_TYPE => $this->individualPlaceLinks($place)->count(), 298 Location::RECORD_TYPE => $this->locationPlaceLinks($place)->count(), 299 ]; 300 301 $sidebar .= view('modules/place-hierarchy/sidebar', [ 302 'showlink' => $show_link, 303 'id' => $id, 304 'place' => $place, 305 'sidebar_class' => $sidebar_class, 306 'stats' => $stats, 307 ]); 308 } 309 310 return [ 311 'bounds' => (new PlaceLocation($placeObj->gedcomName()))->boundingRectangle(), 312 'sidebar' => $sidebar, 313 'markers' => [ 314 'type' => 'FeatureCollection', 315 'features' => $features, 316 ], 317 ]; 318 } 319 320 /** 321 * @param Tree $tree 322 * 323 * @return array<array<Place>> 324 */ 325 private function getList(Tree $tree): array 326 { 327 $places = $this->search_service->searchPlaces($tree, '') 328 ->sort(static function (Place $x, Place $y): int { 329 return $x->gedcomName() <=> $y->gedcomName(); 330 }) 331 ->all(); 332 333 $count = count($places); 334 335 if ($places === []) { 336 return []; 337 } 338 339 $columns = $count > 20 ? 3 : 2; 340 341 return array_chunk($places, (int) ceil($count / $columns)); 342 } 343 344 /** 345 * @param Place $place 346 * 347 * @return array{'tree':Tree,'col_class':string,'columns':array<array<Place>>,'place':Place}|null 348 */ 349 private function getHierarchy(Place $place): ?array 350 { 351 $child_places = $place->getChildPlaces(); 352 $numfound = count($child_places); 353 354 if ($numfound > 0) { 355 $divisor = $numfound > 20 ? 3 : 2; 356 357 return [ 358 'tree' => $place->tree(), 359 'col_class' => 'w-' . ($divisor === 2 ? '25' : '50'), 360 'columns' => array_chunk($child_places, (int) ceil($numfound / $divisor)), 361 'place' => $place, 362 ]; 363 } 364 365 return null; 366 } 367 368 /** 369 * @param Place $place 370 * 371 * @return array{'breadcrumbs':array<Place>,'current':Place|null} 372 */ 373 private function breadcrumbs(Place $place): array 374 { 375 $breadcrumbs = []; 376 if ($place->gedcomName() !== '') { 377 $breadcrumbs[] = $place; 378 $parent_place = $place->parent(); 379 while ($parent_place->gedcomName() !== '') { 380 $breadcrumbs[] = $parent_place; 381 $parent_place = $parent_place->parent(); 382 } 383 $breadcrumbs = array_reverse($breadcrumbs); 384 $current = array_pop($breadcrumbs); 385 } else { 386 $current = null; 387 } 388 389 return [ 390 'breadcrumbs' => $breadcrumbs, 391 'current' => $current, 392 ]; 393 } 394 395 /** 396 * @param Place $place 397 * 398 * @return Builder 399 */ 400 private function placeLinks(Place $place): Builder 401 { 402 return DB::table('places') 403 ->join('placelinks', static function (JoinClause $join): void { 404 $join 405 ->on('pl_file', '=', 'p_file') 406 ->on('pl_p_id', '=', 'p_id'); 407 }) 408 ->where('p_file', '=', $place->tree()->id()) 409 ->where('p_id', '=', $place->id()); 410 } 411 412 /** 413 * @param Place $place 414 * 415 * @return Builder 416 */ 417 private function familyPlaceLinks(Place $place): Builder 418 { 419 return $this->placeLinks($place) 420 ->join('families', static function (JoinClause $join): void { 421 $join 422 ->on('pl_file', '=', 'f_file') 423 ->on('pl_gid', '=', 'f_id'); 424 }); 425 } 426 427 /** 428 * @param Place $place 429 * 430 * @return Builder 431 */ 432 private function individualPlaceLinks(Place $place): Builder 433 { 434 return $this->placeLinks($place) 435 ->join('individuals', static function (JoinClause $join): void { 436 $join 437 ->on('pl_file', '=', 'i_file') 438 ->on('pl_gid', '=', 'i_id'); 439 }); 440 } 441 442 /** 443 * @param Place $place 444 * 445 * @return Builder 446 */ 447 private function locationPlaceLinks(Place $place): Builder 448 { 449 return $this->placeLinks($place) 450 ->join('other', static function (JoinClause $join): void { 451 $join 452 ->on('pl_file', '=', 'o_file') 453 ->on('pl_gid', '=', 'o_id'); 454 }) 455 ->where('o_type', '=', Location::RECORD_TYPE); 456 } 457} 458