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 public function description(): string 105 { 106 /* I18N: Description of the “Place hierarchy” module */ 107 return I18N::translate('The place hierarchy.'); 108 } 109 110 /** 111 * CSS class for the URL. 112 * 113 * @return string 114 */ 115 public function listMenuClass(): string 116 { 117 return 'menu-list-plac'; 118 } 119 120 /** 121 * @return array<string> 122 */ 123 public function listUrlAttributes(): array 124 { 125 return []; 126 } 127 128 /** 129 * @param Tree $tree 130 * 131 * @return bool 132 */ 133 public function listIsEmpty(Tree $tree): bool 134 { 135 return !DB::table('places') 136 ->where('p_file', '=', $tree->id()) 137 ->exists(); 138 } 139 140 /** 141 * @param Tree $tree 142 * @param array<bool|int|string|array<string>|null> $parameters 143 * 144 * @return string 145 */ 146 public function listUrl(Tree $tree, array $parameters = []): string 147 { 148 $parameters['tree'] = $tree->name(); 149 150 return route(static::class, $parameters); 151 } 152 153 /** 154 * @param ServerRequestInterface $request 155 * 156 * @return ResponseInterface 157 */ 158 public function handle(ServerRequestInterface $request): ResponseInterface 159 { 160 $tree = Validator::attributes($request)->tree(); 161 $user = Validator::attributes($request)->user(); 162 $place_id = Validator::attributes($request)->integer('place_id', 0); 163 164 Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user); 165 166 $action2 = Validator::queryParams($request)->string('action2', 'hierarchy'); 167 $place = Place::find($place_id, $tree); 168 169 // Request for a non-existent place? 170 if ($place_id !== $place->id()) { 171 return redirect($place->url()); 172 } 173 174 $map_providers = $this->module_service->findByInterface(ModuleMapProviderInterface::class); 175 176 $content = ''; 177 $showmap = $map_providers->isNotEmpty(); 178 $data = null; 179 180 if ($showmap) { 181 $content .= view('modules/place-hierarchy/map', [ 182 'data' => $this->mapData($place), 183 'leaflet_config' => $this->leaflet_js_service->config(), 184 ]); 185 } 186 187 switch ($action2) { 188 case 'list': 189 default: 190 $alt_link = I18N::translate('Show place hierarchy'); 191 $alt_url = $this->listUrl($tree, ['action2' => 'hierarchy', 'place_id' => $place_id]); 192 $content .= view('modules/place-hierarchy/list', ['columns' => $this->getList($tree)]); 193 break; 194 case 'hierarchy': 195 case 'hierarchy-e': 196 $alt_link = I18N::translate('Show all places in a list'); 197 $alt_url = $this->listUrl($tree, ['action2' => 'list', 'place_id' => 0]); 198 $data = $this->getHierarchy($place); 199 $content .= ($data === null || $showmap) ? '' : view('place-hierarchy', $data); 200 if ($data === null || $action2 === 'hierarchy-e') { 201 $content .= view('modules/place-hierarchy/events', [ 202 'indilist' => $this->search_service->searchIndividualsInPlace($place), 203 'famlist' => $this->search_service->searchFamiliesInPlace($place), 204 'tree' => $place->tree(), 205 ]); 206 } 207 } 208 209 if ($data !== null && $action2 !== 'hierarchy-e' && $place->gedcomName() !== '') { 210 $events_link = $this->listUrl($tree, ['action2' => 'hierarchy-e', 'place_id' => $place_id]); 211 } else { 212 $events_link = ''; 213 } 214 215 $breadcrumbs = $this->breadcrumbs($place); 216 217 return $this->viewResponse('modules/place-hierarchy/page', [ 218 'alt_link' => $alt_link, 219 'alt_url' => $alt_url, 220 'breadcrumbs' => $breadcrumbs['breadcrumbs'], 221 'content' => $content, 222 'current' => $breadcrumbs['current'], 223 'events_link' => $events_link, 224 'place' => $place, 225 'title' => I18N::translate('Place hierarchy'), 226 'tree' => $tree, 227 'world_url' => $this->listUrl($tree), 228 ]); 229 } 230 231 /** 232 * @param Place $place 233 * 234 * @return array<mixed> 235 */ 236 protected function mapData(Place $place): array 237 { 238 $children = $place->getChildPlaces(); 239 $features = []; 240 $sidebar = ''; 241 $show_link = true; 242 243 // No children? Show ourself on the map instead. 244 if ($children === []) { 245 $children[] = $place; 246 $show_link = false; 247 } 248 249 foreach ($children as $id => $child) { 250 $location = new PlaceLocation($child->gedcomName()); 251 252 if (Auth::isAdmin()) { 253 $this_url = route(self::class, ['tree' => $child->tree()->name(), 'place_id' => $place->id()]); 254 $edit_url = route(MapDataEdit::class, ['location_id' => $location->id(), 'url' => $this_url]); 255 } else { 256 $edit_url = ''; 257 } 258 259 if ($location->latitude() === null || $location->longitude() === null) { 260 $sidebar_class = 'unmapped'; 261 } else { 262 $sidebar_class = 'mapped'; 263 $features[] = [ 264 'type' => 'Feature', 265 'id' => $id, 266 'geometry' => [ 267 'type' => 'Point', 268 'coordinates' => [$location->longitude(), $location->latitude()], 269 ], 270 'properties' => [ 271 'tooltip' => $child->gedcomName(), 272 'popup' => view('modules/place-hierarchy/popup', [ 273 'edit_url' => $edit_url, 274 'place' => $child, 275 'latitude' => $location->latitude(), 276 'longitude' => $location->longitude(), 277 'showlink' => $show_link, 278 ]), 279 ], 280 ]; 281 } 282 283 $stats = [ 284 Family::RECORD_TYPE => $this->familyPlaceLinks($child)->count(), 285 Individual::RECORD_TYPE => $this->individualPlaceLinks($child)->count(), 286 Location::RECORD_TYPE => $this->locationPlaceLinks($child)->count(), 287 ]; 288 289 $sidebar .= view('modules/place-hierarchy/sidebar', [ 290 'edit_url' => $edit_url, 291 'id' => $id, 292 'place' => $child, 293 'showlink' => $show_link, 294 'sidebar_class' => $sidebar_class, 295 'stats' => $stats, 296 ]); 297 } 298 299 return [ 300 'bounds' => (new PlaceLocation($place->gedcomName()))->boundingRectangle(), 301 'sidebar' => $sidebar, 302 'markers' => [ 303 'type' => 'FeatureCollection', 304 'features' => $features, 305 ], 306 ]; 307 } 308 309 /** 310 * @param Tree $tree 311 * 312 * @return array<array<Place>> 313 */ 314 private function getList(Tree $tree): array 315 { 316 $places = $this->search_service->searchPlaces($tree, '') 317 ->sort(static fn (Place $x, Place $y): int => I18N::comparator()($x->gedcomName(), $y->gedcomName())) 318 ->all(); 319 320 $count = count($places); 321 322 if ($places === []) { 323 return []; 324 } 325 326 $columns = $count > 20 ? 3 : 2; 327 328 return array_chunk($places, (int) ceil($count / $columns)); 329 } 330 331 /** 332 * @param Place $place 333 * 334 * @return array{columns:array<array<Place>>,place:Place,tree:Tree,col_class:string}|null 335 */ 336 private function getHierarchy(Place $place): array|null 337 { 338 $child_places = $place->getChildPlaces(); 339 $numfound = count($child_places); 340 341 if ($numfound > 0) { 342 $divisor = $numfound > 20 ? 3 : 2; 343 344 return [ 345 'tree' => $place->tree(), 346 'col_class' => 'w-' . ($divisor === 2 ? '25' : '50'), 347 'columns' => array_chunk($child_places, (int) ceil($numfound / $divisor)), 348 'place' => $place, 349 ]; 350 } 351 352 return null; 353 } 354 355 /** 356 * @param Place $place 357 * 358 * @return array{breadcrumbs:array<Place>,current:Place|null} 359 */ 360 private function breadcrumbs(Place $place): array 361 { 362 $breadcrumbs = []; 363 if ($place->gedcomName() !== '') { 364 $breadcrumbs[] = $place; 365 $parent_place = $place->parent(); 366 while ($parent_place->gedcomName() !== '') { 367 $breadcrumbs[] = $parent_place; 368 $parent_place = $parent_place->parent(); 369 } 370 $breadcrumbs = array_reverse($breadcrumbs); 371 $current = array_pop($breadcrumbs); 372 } else { 373 $current = null; 374 } 375 376 return [ 377 'breadcrumbs' => $breadcrumbs, 378 'current' => $current, 379 ]; 380 } 381 382 /** 383 * @param Place $place 384 * 385 * @return Builder 386 */ 387 private function placeLinks(Place $place): Builder 388 { 389 return DB::table('places') 390 ->join('placelinks', static function (JoinClause $join): void { 391 $join 392 ->on('pl_file', '=', 'p_file') 393 ->on('pl_p_id', '=', 'p_id'); 394 }) 395 ->where('p_file', '=', $place->tree()->id()) 396 ->where('p_id', '=', $place->id()); 397 } 398 399 /** 400 * @param Place $place 401 * 402 * @return Builder 403 */ 404 private function familyPlaceLinks(Place $place): Builder 405 { 406 return $this->placeLinks($place) 407 ->join('families', static function (JoinClause $join): void { 408 $join 409 ->on('pl_file', '=', 'f_file') 410 ->on('pl_gid', '=', 'f_id'); 411 }); 412 } 413 414 /** 415 * @param Place $place 416 * 417 * @return Builder 418 */ 419 private function individualPlaceLinks(Place $place): Builder 420 { 421 return $this->placeLinks($place) 422 ->join('individuals', static function (JoinClause $join): void { 423 $join 424 ->on('pl_file', '=', 'i_file') 425 ->on('pl_gid', '=', 'i_id'); 426 }); 427 } 428 429 /** 430 * @param Place $place 431 * 432 * @return Builder 433 */ 434 private function locationPlaceLinks(Place $place): Builder 435 { 436 return $this->placeLinks($place) 437 ->join('other', static function (JoinClause $join): void { 438 $join 439 ->on('pl_file', '=', 'o_file') 440 ->on('pl_gid', '=', 'o_id'); 441 }) 442 ->where('o_type', '=', Location::RECORD_TYPE); 443 } 444} 445