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