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