1 | <?php |
||
2 | |||
3 | /** |
||
4 | * webtrees: online genealogy |
||
5 | * Copyright (C) 2025 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 | |||
18 | declare(strict_types=1); |
||
19 | |||
20 | namespace Fisharebest\Webtrees\Module; |
||
21 | |||
22 | use Fig\Http\Message\RequestMethodInterface; |
||
23 | use Fisharebest\Webtrees\Auth; |
||
24 | use Fisharebest\Webtrees\Http\Middleware\AuthNotRobot; |
||
25 | use Fisharebest\Webtrees\I18N; |
||
26 | use Fisharebest\Webtrees\Individual; |
||
27 | use Fisharebest\Webtrees\Menu; |
||
28 | use Fisharebest\Webtrees\Registry; |
||
29 | use Fisharebest\Webtrees\Services\ChartService; |
||
30 | use Fisharebest\Webtrees\Validator; |
||
31 | use Psr\Http\Message\ResponseInterface; |
||
32 | use Psr\Http\Message\ServerRequestInterface; |
||
33 | use Psr\Http\Server\RequestHandlerInterface; |
||
34 | |||
35 | use function route; |
||
36 | use function view; |
||
37 | |||
38 | class PedigreeChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface |
||
39 | { |
||
40 | use ModuleChartTrait; |
||
41 | |||
42 | protected const string ROUTE_URL = '/tree/{tree}/pedigree-{style}-{generations}/{xref}'; |
||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||
43 | |||
44 | // Chart styles |
||
45 | public const string STYLE_LEFT = 'left'; |
||
46 | public const string STYLE_RIGHT = 'right'; |
||
47 | public const string STYLE_UP = 'up'; |
||
48 | public const string STYLE_DOWN = 'down'; |
||
49 | |||
50 | // Defaults |
||
51 | public const string DEFAULT_GENERATIONS = '4'; |
||
52 | public const string DEFAULT_STYLE = self::STYLE_RIGHT; |
||
53 | protected const array DEFAULT_PARAMETERS = [ |
||
54 | 'generations' => self::DEFAULT_GENERATIONS, |
||
55 | 'style' => self::DEFAULT_STYLE, |
||
56 | ]; |
||
57 | |||
58 | // Limits |
||
59 | protected const int MINIMUM_GENERATIONS = 2; |
||
60 | protected const int MAXIMUM_GENERATIONS = 12; |
||
61 | |||
62 | // For RTL languages |
||
63 | protected const array MIRROR_STYLE = [ |
||
64 | self::STYLE_UP => self::STYLE_DOWN, |
||
65 | self::STYLE_DOWN => self::STYLE_UP, |
||
66 | self::STYLE_LEFT => self::STYLE_RIGHT, |
||
67 | self::STYLE_RIGHT => self::STYLE_LEFT, |
||
68 | ]; |
||
69 | |||
70 | private ChartService $chart_service; |
||
71 | |||
72 | /** |
||
73 | * @param ChartService $chart_service |
||
74 | */ |
||
75 | public function __construct(ChartService $chart_service) |
||
76 | { |
||
77 | $this->chart_service = $chart_service; |
||
78 | } |
||
79 | |||
80 | /** |
||
81 | * Initialization. |
||
82 | * |
||
83 | * @return void |
||
84 | */ |
||
85 | public function boot(): void |
||
86 | { |
||
87 | Registry::routeFactory()->routeMap() |
||
88 | ->get(static::class, static::ROUTE_URL, $this) |
||
89 | ->allows(RequestMethodInterface::METHOD_POST) |
||
90 | ->extras(['middleware' => [AuthNotRobot::class]]); |
||
91 | } |
||
92 | |||
93 | public function title(): string |
||
94 | { |
||
95 | /* I18N: Name of a module/chart */ |
||
96 | return I18N::translate('Pedigree'); |
||
97 | } |
||
98 | |||
99 | public function description(): string |
||
100 | { |
||
101 | /* I18N: Description of the “PedigreeChart” module */ |
||
102 | return I18N::translate('A chart of an individual’s ancestors, formatted as a tree.'); |
||
103 | } |
||
104 | |||
105 | /** |
||
106 | * CSS class for the URL. |
||
107 | * |
||
108 | * @return string |
||
109 | */ |
||
110 | public function chartMenuClass(): string |
||
111 | { |
||
112 | return 'menu-chart-pedigree'; |
||
113 | } |
||
114 | |||
115 | /** |
||
116 | * Return a menu item for this chart - for use in individual boxes. |
||
117 | * |
||
118 | * @param Individual $individual |
||
119 | * |
||
120 | * @return Menu|null |
||
121 | */ |
||
122 | public function chartBoxMenu(Individual $individual): Menu|null |
||
123 | { |
||
124 | return $this->chartMenu($individual); |
||
125 | } |
||
126 | |||
127 | /** |
||
128 | * The title for a specific instance of this chart. |
||
129 | * |
||
130 | * @param Individual $individual |
||
131 | * |
||
132 | * @return string |
||
133 | */ |
||
134 | public function chartTitle(Individual $individual): string |
||
135 | { |
||
136 | /* I18N: %s is an individual’s name */ |
||
137 | return I18N::translate('Pedigree tree of %s', $individual->fullName()); |
||
138 | } |
||
139 | |||
140 | /** |
||
141 | * The URL for a page showing chart options. |
||
142 | * |
||
143 | * @param Individual $individual |
||
144 | * @param array<bool|int|string|array<string>|null> $parameters |
||
145 | * |
||
146 | * @return string |
||
147 | */ |
||
148 | public function chartUrl(Individual $individual, array $parameters = []): string |
||
149 | { |
||
150 | return route(static::class, [ |
||
151 | 'xref' => $individual->xref(), |
||
152 | 'tree' => $individual->tree()->name(), |
||
153 | ] + $parameters + static::DEFAULT_PARAMETERS); |
||
154 | } |
||
155 | |||
156 | /** |
||
157 | * @param ServerRequestInterface $request |
||
158 | * |
||
159 | * @return ResponseInterface |
||
160 | */ |
||
161 | public function handle(ServerRequestInterface $request): ResponseInterface |
||
162 | { |
||
163 | $tree = Validator::attributes($request)->tree(); |
||
164 | $user = Validator::attributes($request)->user(); |
||
165 | $xref = Validator::attributes($request)->isXref()->string('xref'); |
||
166 | $style = Validator::attributes($request)->isInArrayKeys($this->styles('ltr'))->string('style'); |
||
167 | $generations = Validator::attributes($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'); |
||
168 | $ajax = Validator::queryParams($request)->boolean('ajax', false); |
||
169 | |||
170 | // Convert POST requests into GET requests for pretty URLs. |
||
171 | if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { |
||
172 | return redirect(route(self::class, [ |
||
173 | 'tree' => $tree->name(), |
||
174 | 'xref' => Validator::parsedBody($request)->isXref()->string('xref'), |
||
175 | 'style' => Validator::parsedBody($request)->isInArrayKeys($this->styles('ltr'))->string('style'), |
||
176 | 'generations' => Validator::parsedBody($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'), |
||
177 | ])); |
||
178 | } |
||
179 | |||
180 | Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); |
||
181 | |||
182 | $individual = Registry::individualFactory()->make($xref, $tree); |
||
183 | $individual = Auth::checkIndividualAccess($individual, false, true); |
||
184 | |||
185 | if ($ajax) { |
||
186 | $this->layout = 'layouts/ajax'; |
||
187 | |||
188 | $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations); |
||
189 | |||
190 | // Father’s ancestors link to the father’s pedigree |
||
191 | // Mother’s ancestors link to the mother’s pedigree.. |
||
192 | $links = $ancestors->map(function (Individual|null $individual, $sosa) use ($ancestors, $style, $generations): string { |
||
193 | if ($individual instanceof Individual && $sosa >= 2 ** $generations / 2 && $individual->childFamilies()->isNotEmpty()) { |
||
194 | // The last row/column, and there are more generations. |
||
195 | if ($sosa >= 2 ** $generations * 3 / 4) { |
||
196 | return $this->nextLink($ancestors->get(3), $style, $generations); |
||
197 | } |
||
198 | |||
199 | return $this->nextLink($ancestors->get(2), $style, $generations); |
||
200 | } |
||
201 | |||
202 | // A spacer to fix the "Left" layout. |
||
203 | return '<span class="invisible px-2">' . view('icons/arrow-' . $style) . '</span>'; |
||
204 | }); |
||
205 | |||
206 | // Root individual links to their children. |
||
207 | $links->put(1, $this->previousLink($individual, $style, $generations)); |
||
208 | |||
209 | return $this->viewResponse('modules/pedigree-chart/chart', [ |
||
210 | 'ancestors' => $ancestors, |
||
211 | 'generations' => $generations, |
||
212 | 'style' => $style, |
||
213 | 'layout' => 'right', |
||
214 | 'links' => $links, |
||
215 | 'spacer' => $this->spacer(), |
||
216 | ]); |
||
217 | } |
||
218 | |||
219 | $ajax_url = $this->chartUrl($individual, [ |
||
220 | 'ajax' => true, |
||
221 | 'generations' => $generations, |
||
222 | 'style' => $style, |
||
223 | 'xref' => $xref, |
||
224 | ]); |
||
225 | |||
226 | return $this->viewResponse('modules/pedigree-chart/page', [ |
||
227 | 'ajax_url' => $ajax_url, |
||
228 | 'generations' => $generations, |
||
229 | 'individual' => $individual, |
||
230 | 'module' => $this->name(), |
||
231 | 'max_generations' => self::MAXIMUM_GENERATIONS, |
||
232 | 'min_generations' => self::MINIMUM_GENERATIONS, |
||
233 | 'style' => $style, |
||
234 | 'styles' => $this->styles(I18N::direction()), |
||
235 | 'title' => $this->chartTitle($individual), |
||
236 | 'tree' => $tree, |
||
237 | ]); |
||
238 | } |
||
239 | |||
240 | /** |
||
241 | * A link-sized spacer, to maintain the chart layout |
||
242 | * |
||
243 | * @return string |
||
244 | */ |
||
245 | public function spacer(): string |
||
246 | { |
||
247 | return '<span class="px-2">' . view('icons/spacer') . '</span>'; |
||
248 | } |
||
249 | |||
250 | /** |
||
251 | * Build a menu for the chart root individual |
||
252 | * |
||
253 | * @param Individual $individual |
||
254 | * @param string $style |
||
255 | * @param int $generations |
||
256 | * |
||
257 | * @return string |
||
258 | */ |
||
259 | public function nextLink(Individual $individual, string $style, int $generations): string |
||
260 | { |
||
261 | $icon = view('icons/arrow-' . $style); |
||
262 | $title = $this->chartTitle($individual); |
||
263 | $url = $this->chartUrl($individual, [ |
||
264 | 'style' => $style, |
||
265 | 'generations' => $generations, |
||
266 | ]); |
||
267 | |||
268 | return '<a class="px-2" href="' . e($url) . '" title="' . strip_tags($title) . '">' . $icon . '<span class="visually-hidden">' . $title . '</span></a>'; |
||
269 | } |
||
270 | |||
271 | /** |
||
272 | * Build a menu for the chart root individual |
||
273 | * |
||
274 | * @param Individual $individual |
||
275 | * @param string $style |
||
276 | * @param int $generations |
||
277 | * |
||
278 | * @return string |
||
279 | */ |
||
280 | public function previousLink(Individual $individual, string $style, int $generations): string |
||
281 | { |
||
282 | $icon = view('icons/arrow-' . self::MIRROR_STYLE[$style]); |
||
283 | |||
284 | $siblings = []; |
||
285 | $spouses = []; |
||
286 | $children = []; |
||
287 | |||
288 | foreach ($individual->childFamilies() as $family) { |
||
289 | foreach ($family->children() as $child) { |
||
290 | if ($child !== $individual) { |
||
291 | $siblings[] = $this->individualLink($child, $style, $generations); |
||
292 | } |
||
293 | } |
||
294 | } |
||
295 | |||
296 | foreach ($individual->spouseFamilies() as $family) { |
||
297 | foreach ($family->spouses() as $spouse) { |
||
298 | if ($spouse !== $individual) { |
||
299 | $spouses[] = $this->individualLink($spouse, $style, $generations); |
||
300 | } |
||
301 | } |
||
302 | |||
303 | foreach ($family->children() as $child) { |
||
304 | $children[] = $this->individualLink($child, $style, $generations); |
||
305 | } |
||
306 | } |
||
307 | |||
308 | return view('modules/pedigree-chart/previous', [ |
||
309 | 'icon' => $icon, |
||
310 | 'individual' => $individual, |
||
311 | 'generations' => $generations, |
||
312 | 'style' => $style, |
||
313 | 'chart' => $this, |
||
314 | 'siblings' => $siblings, |
||
315 | 'spouses' => $spouses, |
||
316 | 'children' => $children, |
||
317 | ]); |
||
318 | } |
||
319 | |||
320 | /** |
||
321 | * @param Individual $individual |
||
322 | * @param string $style |
||
323 | * @param int $generations |
||
324 | * |
||
325 | * @return string |
||
326 | */ |
||
327 | protected function individualLink(Individual $individual, string $style, int $generations): string |
||
328 | { |
||
329 | $text = $individual->fullName(); |
||
330 | $title = $this->chartTitle($individual); |
||
331 | $url = $this->chartUrl($individual, [ |
||
332 | 'style' => $style, |
||
333 | 'generations' => $generations, |
||
334 | ]); |
||
335 | |||
336 | return '<a class="dropdown-item" href="' . e($url) . '" title="' . strip_tags($title) . '">' . $text . '</a>'; |
||
337 | } |
||
338 | |||
339 | /** |
||
340 | * This chart can display its output in a number of styles |
||
341 | * |
||
342 | * @param string $direction |
||
343 | * |
||
344 | * @return array<string> |
||
345 | */ |
||
346 | protected function styles(string $direction): array |
||
347 | { |
||
348 | // On right-to-left pages, the CSS will mirror the chart, so we need to mirror the label. |
||
349 | if ($direction === 'rtl') { |
||
350 | return [ |
||
351 | self::STYLE_RIGHT => view('icons/pedigree-left') . I18N::translate('left'), |
||
352 | self::STYLE_LEFT => view('icons/pedigree-right') . I18N::translate('right'), |
||
353 | self::STYLE_UP => view('icons/pedigree-up') . I18N::translate('up'), |
||
354 | self::STYLE_DOWN => view('icons/pedigree-down') . I18N::translate('down'), |
||
355 | ]; |
||
356 | } |
||
357 | |||
358 | return [ |
||
359 | self::STYLE_LEFT => view('icons/pedigree-left') . I18N::translate('left'), |
||
360 | self::STYLE_RIGHT => view('icons/pedigree-right') . I18N::translate('right'), |
||
361 | self::STYLE_UP => view('icons/pedigree-up') . I18N::translate('up'), |
||
362 | self::STYLE_DOWN => view('icons/pedigree-down') . I18N::translate('down'), |
||
363 | ]; |
||
364 | } |
||
365 | } |
||
366 |