Issues (2491)

app/Module/TimelineChartModule.php (1 issue)

Labels
Severity
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\Date\GregorianDate;
25
use Fisharebest\Webtrees\Fact;
26
use Fisharebest\Webtrees\GedcomRecord;
27
use Fisharebest\Webtrees\Http\Middleware\AuthNotRobot;
28
use Fisharebest\Webtrees\I18N;
29
use Fisharebest\Webtrees\Individual;
30
use Fisharebest\Webtrees\Registry;
31
use Fisharebest\Webtrees\Tree;
32
use Fisharebest\Webtrees\Validator;
33
use Illuminate\Support\Collection;
34
use Psr\Http\Message\ResponseInterface;
35
use Psr\Http\Message\ServerRequestInterface;
36
use Psr\Http\Server\RequestHandlerInterface;
37
38
use function in_array;
39
use function redirect;
40
use function route;
41
42
class TimelineChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface
43
{
44
    use ModuleChartTrait;
45
46
    protected const string ROUTE_URL = '/tree/{tree}/timeline-{scale}';
0 ignored issues
show
A parse error occurred: Syntax error, unexpected T_STRING, expecting '=' on line 46 at column 27
Loading history...
47
48
    // Defaults
49
    protected const int DEFAULT_SCALE        = 10;
50
    protected const array DEFAULT_PARAMETERS = [
51
        'scale' => self::DEFAULT_SCALE,
52
    ];
53
54
    // Limits
55
    protected const int MINIMUM_SCALE = 1;
56
    protected const int MAXIMUM_SCALE = 200;
57
58
    // GEDCOM events that may have DATE data, but should not be displayed
59
    protected const array NON_FACTS = [
60
        'FAM:CHAN',
61
        'INDI:BAPL',
62
        'INDI:CHAN',
63
        'INDI:ENDL',
64
        'INDI:SLGC',
65
        'INDI:SLGS',
66
        'INDI:_TODO',
67
    ];
68
69
    protected const int BOX_HEIGHT = 30;
70
71
    /**
72
     * Initialization.
73
     *
74
     * @return void
75
     */
76
    public function boot(): void
77
    {
78
        Registry::routeFactory()->routeMap()
79
            ->get(static::class, static::ROUTE_URL, $this)
80
            ->allows(RequestMethodInterface::METHOD_POST)
81
            ->extras(['middleware' => [AuthNotRobot::class]]);
82
    }
83
84
    public function title(): string
85
    {
86
        /* I18N: Name of a module/chart */
87
        return I18N::translate('Timeline');
88
    }
89
90
    public function description(): string
91
    {
92
        /* I18N: Description of the “TimelineChart” module */
93
        return I18N::translate('A timeline displaying individual events.');
94
    }
95
96
    /**
97
     * CSS class for the URL.
98
     *
99
     * @return string
100
     */
101
    public function chartMenuClass(): string
102
    {
103
        return 'menu-chart-timeline';
104
    }
105
106
    /**
107
     * The URL for this chart.
108
     *
109
     * @param Individual                                $individual
110
     * @param array<bool|int|string|array<string>|null> $parameters
111
     *
112
     * @return string
113
     */
114
    public function chartUrl(Individual $individual, array $parameters = []): string
115
    {
116
        return route(static::class, [
117
                'tree'  => $individual->tree()->name(),
118
                'xrefs' => [$individual->xref()],
119
            ] + $parameters + self::DEFAULT_PARAMETERS);
120
    }
121
122
    /**
123
     * @param ServerRequestInterface $request
124
     *
125
     * @return ResponseInterface
126
     */
127
    public function handle(ServerRequestInterface $request): ResponseInterface
128
    {
129
        $tree  = Validator::attributes($request)->tree();
130
        $user  = Validator::attributes($request)->user();
131
        $scale = Validator::attributes($request)->isBetween(self::MINIMUM_SCALE, self::MAXIMUM_SCALE)->integer('scale');
132
        $xrefs = Validator::queryParams($request)->array('xrefs');
133
        $ajax  = Validator::queryParams($request)->boolean('ajax', false);
134
        $xrefs = array_filter(array_unique($xrefs));
135
136
        // Convert POST requests into GET requests for pretty URLs.
137
        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
138
            $xrefs[] = Validator::parsedBody($request)->isXref()->string('add', '');
139
140
            return redirect(route(static::class, [
141
                'tree'  => $tree->name(),
142
                'scale' => $scale,
143
                'xrefs' => $xrefs,
144
            ]));
145
        }
146
147
        Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user);
148
149
        // Find the requested individuals.
150
        $individuals = (new Collection($xrefs))
151
            ->uniqueStrict()
152
            ->map(static fn (string $xref): Individual|null => Registry::individualFactory()->make($xref, $tree))
153
            ->filter()
154
            ->filter(GedcomRecord::accessFilter());
155
156
        // Generate URLs omitting each xref.
157
        $remove_urls = [];
158
159
        foreach ($individuals as $exclude) {
160
            $xrefs_1 = $individuals
161
                ->filter(static fn (Individual $individual): bool => $individual->xref() !== $exclude->xref())
162
                ->map(static fn (Individual $individual): string => $individual->xref());
163
164
            $remove_urls[$exclude->xref()] = route(static::class, [
165
                'tree'  => $tree->name(),
166
                'scale' => $scale,
167
                'xrefs' => $xrefs_1->all(),
168
            ]);
169
        }
170
171
        $individuals = array_map(static fn (string $xref): Individual|null => Registry::individualFactory()->make($xref, $tree), $xrefs);
172
173
        $individuals = array_filter($individuals, static fn (Individual|null $individual): bool => $individual instanceof Individual && $individual->canShow());
174
175
        if ($ajax) {
176
            $this->layout = 'layouts/ajax';
177
178
            return $this->chart($tree, $xrefs, $scale);
179
        }
180
181
        $reset_url = route(static::class, [
182
            'scale' => self::DEFAULT_SCALE,
183
            'tree'  => $tree->name(),
184
        ]);
185
186
        $zoom_in_url = route(static::class, [
187
            'scale' => min(self::MAXIMUM_SCALE, $scale + (int) ($scale * 0.4 + 1)),
188
            'tree'  => $tree->name(),
189
            'xrefs' => $xrefs,
190
        ]);
191
192
        $zoom_out_url = route(static::class, [
193
            'scale' => max(self::MINIMUM_SCALE, $scale - (int) ($scale * 0.4 + 1)),
194
            'tree'  => $tree->name(),
195
            'xrefs' => $xrefs,
196
        ]);
197
198
        $ajax_url = route(static::class, [
199
            'ajax'  => true,
200
            'scale' => $scale,
201
            'tree'  => $tree->name(),
202
            'xrefs' => $xrefs,
203
        ]);
204
205
        return $this->viewResponse('modules/timeline-chart/page', [
206
            'ajax_url'     => $ajax_url,
207
            'individuals'  => $individuals,
208
            'module'       => $this->name(),
209
            'remove_urls'  => $remove_urls,
210
            'reset_url'    => $reset_url,
211
            'scale'        => $scale,
212
            'title'        => $this->title(),
213
            'tree'         => $tree,
214
            'zoom_in_url'  => $zoom_in_url,
215
            'zoom_out_url' => $zoom_out_url,
216
        ]);
217
    }
218
219
    /**
220
     * @param Tree          $tree
221
     * @param array<string> $xrefs
222
     * @param int           $scale
223
     *
224
     * @return ResponseInterface
225
     */
226
    protected function chart(Tree $tree, array $xrefs, int $scale): ResponseInterface
227
    {
228
        /** @var Individual[] $individuals */
229
        $individuals = array_map(static fn (string $xref): Individual|null => Registry::individualFactory()->make($xref, $tree), $xrefs);
230
231
        $individuals = array_filter($individuals, static fn (Individual|null $individual): bool => $individual instanceof Individual && $individual->canShow());
232
233
        $baseyear    = (int) date('Y');
234
        $topyear     = 0;
235
        $indifacts   = new Collection();
236
        $birthyears  = [];
237
        $birthmonths = [];
238
        $birthdays   = [];
239
240
        foreach ($individuals as $individual) {
241
            $bdate = $individual->getBirthDate();
242
            if ($bdate->isOK()) {
243
                $date = new GregorianDate($bdate->minimumJulianDay());
244
245
                $birthyears [$individual->xref()] = $date->year;
246
                $birthmonths[$individual->xref()] = max(1, $date->month);
247
                $birthdays  [$individual->xref()] = max(1, $date->day);
248
            }
249
            // find all the fact information
250
            $facts = $individual->facts();
251
            foreach ($individual->spouseFamilies() as $family) {
252
                foreach ($family->facts() as $fact) {
253
                    $facts->push($fact);
254
                }
255
            }
256
257
            foreach ($facts as $event) {
258
                if (!in_array($event->tag(), self::NON_FACTS, true)) {
259
                    // check for a date
260
                    $date = $event->date();
261
                    if ($date->isOK()) {
262
                        $date     = new GregorianDate($date->minimumJulianDay());
263
                        $baseyear = min($baseyear, $date->year);
264
                        $topyear  = max($topyear, $date->year);
265
266
                        if (!$individual->isDead()) {
267
                            $topyear = max($topyear, (int) date('Y'));
268
                        }
269
270
                        $indifacts->push($event);
271
                    }
272
                }
273
            }
274
        }
275
276
        // do not add the same fact twice (prevents marriages from being added multiple times)
277
        $indifacts = $indifacts->uniqueStrict(static fn (Fact $fact): string => $fact->id());
278
279
        if ($scale === 0) {
280
            $scale = (int) (($topyear - $baseyear) / 20 * $indifacts->count() / 4);
281
            if ($scale < 6) {
282
                $scale = 6;
283
            }
284
        }
285
        if ($scale < 2) {
286
            $scale = 2;
287
        }
288
        $baseyear -= 5;
289
        $topyear  += 5;
290
291
        $indifacts = Fact::sortFacts($indifacts);
292
293
        $html = view('modules/timeline-chart/chart', [
294
            'baseyear'    => $baseyear,
295
            'bheight'     => self::BOX_HEIGHT,
296
            'birthdays'   => $birthdays,
297
            'birthmonths' => $birthmonths,
298
            'birthyears'  => $birthyears,
299
            'indifacts'   => $indifacts,
300
            'individuals' => $individuals,
301
            'placements'  => [],
302
            'scale'       => $scale,
303
            'topyear'     => $topyear,
304
        ]);
305
306
        return response($html);
307
    }
308
}
309