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