1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2019 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16declare(strict_types=1); 17 18namespace Fisharebest\Webtrees\Module; 19 20use Fisharebest\Webtrees\Date\GregorianDate; 21use Fisharebest\Webtrees\Functions\Functions; 22use Fisharebest\Webtrees\GedcomRecord; 23use Fisharebest\Webtrees\I18N; 24use Fisharebest\Webtrees\Individual; 25use Fisharebest\Webtrees\Tree; 26use Illuminate\Support\Collection; 27use Symfony\Component\HttpFoundation\Request; 28use Symfony\Component\HttpFoundation\Response; 29 30/** 31 * Class TimelineChartModule 32 */ 33class TimelineChartModule extends AbstractModule implements ModuleInterface, ModuleChartInterface 34{ 35 use ModuleChartTrait; 36 37 // The user can alter the vertical scale 38 protected const SCALE_MIN = 1; 39 protected const SCALE_MAX = 200; 40 protected const SCALE_DEFAULT = 10; 41 42 // GEDCOM events that may have DATE data, but should not be displayed 43 protected const NON_FACTS = [ 44 'BAPL', 45 'ENDL', 46 'SLGC', 47 'SLGS', 48 '_TODO', 49 'CHAN', 50 ]; 51 52 // Box height 53 protected const BHEIGHT = 30; 54 55 /** 56 * How should this module be labelled on tabs, menus, etc.? 57 * 58 * @return string 59 */ 60 public function title(): string 61 { 62 /* I18N: Name of a module/chart */ 63 return I18N::translate('Timeline'); 64 } 65 66 /** 67 * A sentence describing what this module does. 68 * 69 * @return string 70 */ 71 public function description(): string 72 { 73 /* I18N: Description of the “TimelineChart” module */ 74 return I18N::translate('A timeline displaying individual events.'); 75 } 76 77 /** 78 * CSS class for the URL. 79 * 80 * @return string 81 */ 82 public function chartMenuClass(): string 83 { 84 return 'menu-chart-timeline'; 85 } 86 87 /** 88 * The URL for this chart. 89 * 90 * @param Individual $individual 91 * @param string[] $parameters 92 * 93 * @return string 94 */ 95 public function chartUrl(Individual $individual, array $parameters = []): string 96 { 97 return route('module', [ 98 'module' => $this->name(), 99 'action' => 'Chart', 100 'xrefs[]' => $individual->xref(), 101 'ged' => $individual->tree()->name(), 102 ] + $parameters); 103 } 104 105 /** 106 * A form to request the chart parameters. 107 * 108 * @param Request $request 109 * @param Tree $tree 110 * 111 * @return Response 112 */ 113 public function getChartAction(Request $request, Tree $tree): Response 114 { 115 $ajax = (bool) $request->get('ajax'); 116 $scale = (int) $request->get('scale', self::SCALE_DEFAULT); 117 $scale = min($scale, self::SCALE_MAX); 118 $scale = max($scale, self::SCALE_MIN); 119 120 $xrefs = $request->get('xrefs', []); 121 122 // Find the requested individuals. 123 $individuals = (new Collection($xrefs)) 124 ->unique() 125 ->map(function (string $xref) use ($tree): ?Individual { 126 return Individual::getInstance($xref, $tree); 127 }) 128 ->filter() 129 ->filter(GedcomRecord::accessFilter()); 130 131 // Generate URLs omitting each xref. 132 $remove_urls = []; 133 134 foreach ($individuals as $exclude) { 135 $xrefs_1 = $individuals 136 ->filter(function (Individual $individual) use ($exclude): bool { 137 return $individual->xref() !== $exclude->xref(); 138 }) 139 ->map(function (Individual $individual): string { 140 return $individual->xref(); 141 }); 142 143 $remove_urls[$exclude->xref()] = route('module', [ 144 'module' => $this->name(), 145 'action' => 'Chart', 146 'ged' => $tree->name(), 147 'scale' => $scale, 148 'xrefs' => $xrefs_1->all(), 149 ]); 150 } 151 152 $individuals = array_map(function (string $xref) use ($tree) { 153 return Individual::getInstance($xref, $tree); 154 }, $xrefs); 155 156 if ($ajax) { 157 return $this->chart($tree, $xrefs, $scale); 158 } 159 160 $ajax_url = route('module', [ 161 'ajax' => true, 162 'module' => $this->name(), 163 'action' => 'Chart', 164 'ged' => $tree->name(), 165 'scale' => $scale, 166 'xrefs' => $xrefs, 167 ]); 168 169 $reset_url = route('module', [ 170 'module' => $this->name(), 171 'action' => 'Chart', 172 'ged' => $tree->name(), 173 ]); 174 175 $zoom_in_url = route('module', [ 176 'module' => $this->name(), 177 'action' => 'Chart', 178 'ged' => $tree->name(), 179 'scale' => min(self::SCALE_MAX, $scale + (int) ($scale * 0.2 + 1)), 180 'xrefs' => $xrefs, 181 ]); 182 183 $zoom_out_url = route('module', [ 184 'module' => $this->name(), 185 'action' => 'Chart', 186 'ged' => $tree->name(), 187 'scale' => max(self::SCALE_MIN, $scale - (int) ($scale * 0.2 + 1)), 188 'xrefs' => $xrefs, 189 ]); 190 191 return $this->viewResponse('modules/timeline-chart/page', [ 192 'ajax_url' => $ajax_url, 193 'individuals' => $individuals, 194 'module_name' => $this->name(), 195 'remove_urls' => $remove_urls, 196 'reset_url' => $reset_url, 197 'title' => $this->title(), 198 'scale' => $scale, 199 'zoom_in_url' => $zoom_in_url, 200 'zoom_out_url' => $zoom_out_url, 201 ]); 202 } 203 204 /** 205 * @param Tree $tree 206 * @param array $xrefs 207 * @param int $scale 208 * 209 * @return Response 210 */ 211 protected function chart(Tree $tree, array $xrefs, int $scale): Response 212 { 213 $xrefs = array_unique($xrefs); 214 215 /** @var Individual[] $individuals */ 216 $individuals = array_map(function (string $xref) use ($tree) { 217 return Individual::getInstance($xref, $tree); 218 }, $xrefs); 219 220 $individuals = array_filter($individuals, function (Individual $individual = null): bool { 221 return $individual !== null && $individual->canShow(); 222 }); 223 224 $baseyear = (int) date('Y'); 225 $topyear = 0; 226 $indifacts = []; 227 $birthyears = []; 228 $birthmonths = []; 229 $birthdays = []; 230 231 foreach ($individuals as $individual) { 232 $bdate = $individual->getBirthDate(); 233 if ($bdate->isOK()) { 234 $date = new GregorianDate($bdate->minimumJulianDay()); 235 236 $birthyears [$individual->xref()] = $date->year; 237 $birthmonths[$individual->xref()] = max(1, $date->month); 238 $birthdays [$individual->xref()] = max(1, $date->day); 239 } 240 // find all the fact information 241 $facts = $individual->facts(); 242 foreach ($individual->getSpouseFamilies() as $family) { 243 foreach ($family->facts() as $fact) { 244 $facts[] = $fact; 245 } 246 } 247 foreach ($facts as $event) { 248 // get the fact type 249 $fact = $event->getTag(); 250 if (!in_array($fact, self::NON_FACTS)) { 251 // check for a date 252 $date = $event->date(); 253 if ($date->isOK()) { 254 $date = new GregorianDate($date->minimumJulianDay()); 255 $baseyear = min($baseyear, $date->year); 256 $topyear = max($topyear, $date->year); 257 258 if (!$individual->isDead()) { 259 $topyear = max($topyear, (int) date('Y')); 260 } 261 262 // do not add the same fact twice (prevents marriages from being added multiple times) 263 if (!in_array($event, $indifacts, true)) { 264 $indifacts[] = $event; 265 } 266 } 267 } 268 } 269 } 270 271 if ($scale === 0) { 272 $scale = (int) (($topyear - $baseyear) / 20 * count($indifacts) / 4); 273 if ($scale < 6) { 274 $scale = 6; 275 } 276 } 277 if ($scale < 2) { 278 $scale = 2; 279 } 280 $baseyear -= 5; 281 $topyear += 5; 282 283 Functions::sortFacts($indifacts); 284 285 $html = view('modules/timeline-chart/chart', [ 286 'baseyear' => $baseyear, 287 'bheight' => self::BHEIGHT, 288 'birthdays' => $birthdays, 289 'birthmonths' => $birthmonths, 290 'birthyears' => $birthyears, 291 'indifacts' => $indifacts, 292 'individuals' => $individuals, 293 'placements' => [], 294 'scale' => $scale, 295 'topyear' => $topyear, 296 ]); 297 298 return new Response($html); 299 } 300} 301