1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2021 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 Aura\Router\RouterContainer; 23use Fisharebest\Webtrees\Auth; 24use Fisharebest\Webtrees\Contracts\UserInterface; 25use Fisharebest\Webtrees\Family; 26use Fisharebest\Webtrees\I18N; 27use Fisharebest\Webtrees\Individual; 28use Fisharebest\Webtrees\Location; 29use Fisharebest\Webtrees\Place; 30use Fisharebest\Webtrees\PlaceLocation; 31use Fisharebest\Webtrees\Services\LeafletJsService; 32use Fisharebest\Webtrees\Services\ModuleService; 33use Fisharebest\Webtrees\Services\SearchService; 34use Fisharebest\Webtrees\Statistics; 35use Fisharebest\Webtrees\Tree; 36use Illuminate\Database\Capsule\Manager as DB; 37use Psr\Http\Message\ResponseInterface; 38use Psr\Http\Message\ServerRequestInterface; 39use Psr\Http\Server\RequestHandlerInterface; 40 41use function app; 42use function array_chunk; 43use function array_pop; 44use function array_reverse; 45use function assert; 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'; 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 $router_container = app(RouterContainer::class); 92 assert($router_container instanceof RouterContainer); 93 94 $router_container->getMap() 95 ->get(static::class, static::ROUTE_URL, $this); 96 } 97 98 /** 99 * How should this module be identified in the control panel, etc.? 100 * 101 * @return string 102 */ 103 public function title(): string 104 { 105 /* I18N: Name of a module/list */ 106 return I18N::translate('Place hierarchy'); 107 } 108 109 /** 110 * A sentence describing what this module does. 111 * 112 * @return string 113 */ 114 public function description(): string 115 { 116 /* I18N: Description of the “Place hierarchy” module */ 117 return I18N::translate('The place hierarchy.'); 118 } 119 120 /** 121 * CSS class for the URL. 122 * 123 * @return string 124 */ 125 public function listMenuClass(): string 126 { 127 return 'menu-list-plac'; 128 } 129 130 /** 131 * @return array<string> 132 */ 133 public function listUrlAttributes(): array 134 { 135 return []; 136 } 137 138 /** 139 * @param Tree $tree 140 * 141 * @return bool 142 */ 143 public function listIsEmpty(Tree $tree): bool 144 { 145 return !DB::table('places') 146 ->where('p_file', '=', $tree->id()) 147 ->exists(); 148 } 149 150 /** 151 * Handle URLs generated by older versions of webtrees 152 * 153 * @param ServerRequestInterface $request 154 * 155 * @return ResponseInterface 156 */ 157 public function getListAction(ServerRequestInterface $request): ResponseInterface 158 { 159 return redirect($this->listUrl($request->getAttribute('tree'), $request->getQueryParams())); 160 } 161 162 /** 163 * @param Tree $tree 164 * @param array<bool|int|string|array<string>|null> $parameters 165 * 166 * @return string 167 */ 168 public function listUrl(Tree $tree, array $parameters = []): string 169 { 170 $parameters['tree'] = $tree->name(); 171 172 return route(static::class, $parameters); 173 } 174 175 /** 176 * @param ServerRequestInterface $request 177 * 178 * @return ResponseInterface 179 */ 180 public function handle(ServerRequestInterface $request): ResponseInterface 181 { 182 $tree = $request->getAttribute('tree'); 183 assert($tree instanceof Tree); 184 185 $user = $request->getAttribute('user'); 186 assert($user instanceof UserInterface); 187 188 Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user); 189 190 $action2 = $request->getQueryParams()['action2'] ?? 'hierarchy'; 191 $place_id = (int) ($request->getQueryParams()['place_id'] ?? 0); 192 $place = Place::find($place_id, $tree); 193 194 // Request for a non-existent place? 195 if ($place_id !== $place->id()) { 196 return redirect($place->url()); 197 } 198 199 $map_providers = $this->module_service->findByInterface(ModuleMapProviderInterface::class); 200 201 $content = ''; 202 $showmap = $map_providers->isNotEmpty(); 203 $data = null; 204 205 if ($showmap) { 206 $content .= view('modules/place-hierarchy/map', [ 207 'data' => $this->mapData($tree, $place), 208 'leaflet_config' => $this->leaflet_js_service->config(), 209 ]); 210 } 211 212 switch ($action2) { 213 case 'list': 214 default: 215 $alt_link = I18N::translate('Show place hierarchy'); 216 $alt_url = $this->listUrl($tree, ['action2' => 'hierarchy', 'place_id' => $place_id]); 217 $content .= view('modules/place-hierarchy/list', ['columns' => $this->getList($tree)]); 218 break; 219 case 'hierarchy': 220 case 'hierarchy-e': 221 $alt_link = I18N::translate('Show all places in a list'); 222 $alt_url = $this->listUrl($tree, ['action2' => 'list', 'place_id' => 0]); 223 $data = $this->getHierarchy($place); 224 $content .= (null === $data || $showmap) ? '' : view('place-hierarchy', $data); 225 if (null === $data || $action2 === 'hierarchy-e') { 226 $content .= view('modules/place-hierarchy/events', [ 227 'indilist' => $this->search_service->searchIndividualsInPlace($place), 228 'famlist' => $this->search_service->searchFamiliesInPlace($place), 229 'tree' => $place->tree(), 230 ]); 231 } 232 } 233 234 if ($data !== null && $action2 !== 'hierarchy-e' && $place->gedcomName() !== '') { 235 $events_link = $this->listUrl($tree, ['action2' => 'hierarchy-e', 'place_id' => $place_id]); 236 } else { 237 $events_link = ''; 238 } 239 240 $breadcrumbs = $this->breadcrumbs($place); 241 242 return $this->viewResponse('modules/place-hierarchy/page', [ 243 'alt_link' => $alt_link, 244 'alt_url' => $alt_url, 245 'breadcrumbs' => $breadcrumbs['breadcrumbs'], 246 'content' => $content, 247 'current' => $breadcrumbs['current'], 248 'events_link' => $events_link, 249 'place' => $place, 250 'title' => I18N::translate('Place hierarchy'), 251 'tree' => $tree, 252 'world_url' => $this->listUrl($tree), 253 ]); 254 } 255 256 /** 257 * @param Tree $tree 258 * @param Place $placeObj 259 * 260 * @return array<mixed> 261 */ 262 protected function mapData(Tree $tree, Place $placeObj): array 263 { 264 $places = $placeObj->getChildPlaces(); 265 $features = []; 266 $sidebar = ''; 267 $show_link = true; 268 269 if ($places === []) { 270 $places[] = $placeObj; 271 $show_link = false; 272 } 273 274 foreach ($places as $id => $place) { 275 $location = new PlaceLocation($place->gedcomName()); 276 277 if ($location->latitude() === null || $location->longitude() === null) { 278 $sidebar_class = 'unmapped'; 279 } else { 280 $sidebar_class = 'mapped'; 281 $features[] = [ 282 'type' => 'Feature', 283 'id' => $id, 284 'geometry' => [ 285 'type' => 'Point', 286 'coordinates' => [$location->longitude(), $location->latitude()], 287 ], 288 'properties' => [ 289 'tooltip' => $place->gedcomName(), 290 'popup' => view('modules/place-hierarchy/popup', [ 291 'showlink' => $show_link, 292 'place' => $place, 293 'latitude' => $location->latitude(), 294 'longitude' => $location->longitude(), 295 ]), 296 ], 297 ]; 298 } 299 300 $statistics = app(Statistics::class); 301 302 //Stats 303 $stats = []; 304 foreach ([Individual::RECORD_TYPE, Family::RECORD_TYPE, Location::RECORD_TYPE] as $type) { 305 $tmp = $statistics->statsPlaces($type, '', $place->id()); 306 $stats[$type] = $tmp === [] ? 0 : $tmp[0]->tot; 307 } 308 $sidebar .= view('modules/place-hierarchy/sidebar', [ 309 'showlink' => $show_link, 310 'id' => $id, 311 'place' => $place, 312 'sidebar_class' => $sidebar_class, 313 'stats' => $stats, 314 ]); 315 } 316 317 return [ 318 'bounds' => (new PlaceLocation($placeObj->gedcomName()))->boundingRectangle(), 319 'sidebar' => $sidebar, 320 'markers' => [ 321 'type' => 'FeatureCollection', 322 'features' => $features, 323 ], 324 ]; 325 } 326 327 /** 328 * @param Tree $tree 329 * 330 * @return array<array<Place>> 331 */ 332 private function getList(Tree $tree): array 333 { 334 $places = $this->search_service->searchPlaces($tree, '') 335 ->sort(static function (Place $x, Place $y): int { 336 return $x->gedcomName() <=> $y->gedcomName(); 337 }) 338 ->all(); 339 340 $count = count($places); 341 342 if ($places === []) { 343 return []; 344 } 345 346 $columns = $count > 20 ? 3 : 2; 347 348 return array_chunk($places, (int) ceil($count / $columns)); 349 } 350 351 /** 352 * @param Place $place 353 * 354 * @return array{'tree':Tree,'col_class':string,'columns':array<array<Place>>,'place':Place}|null 355 */ 356 private function getHierarchy(Place $place): ?array 357 { 358 $child_places = $place->getChildPlaces(); 359 $numfound = count($child_places); 360 361 if ($numfound > 0) { 362 $divisor = $numfound > 20 ? 3 : 2; 363 364 return [ 365 'tree' => $place->tree(), 366 'col_class' => 'w-' . ($divisor === 2 ? '25' : '50'), 367 'columns' => array_chunk($child_places, (int) ceil($numfound / $divisor)), 368 'place' => $place, 369 ]; 370 } 371 372 return null; 373 } 374 375 /** 376 * @param Place $place 377 * 378 * @return array{'breadcrumbs':array<Place>,'current':Place|null} 379 */ 380 private function breadcrumbs(Place $place): array 381 { 382 $breadcrumbs = []; 383 if ($place->gedcomName() !== '') { 384 $breadcrumbs[] = $place; 385 $parent_place = $place->parent(); 386 while ($parent_place->gedcomName() !== '') { 387 $breadcrumbs[] = $parent_place; 388 $parent_place = $parent_place->parent(); 389 } 390 $breadcrumbs = array_reverse($breadcrumbs); 391 $current = array_pop($breadcrumbs); 392 } else { 393 $current = null; 394 } 395 396 return [ 397 'breadcrumbs' => $breadcrumbs, 398 'current' => $current, 399 ]; 400 } 401} 402