xref: /webtrees/app/Module/FanChartModule.php (revision 91a257a4cd149099de2291e3d62a1b61f2f06bcd)
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 Fig\Http\Message\RequestMethodInterface;
24use Fisharebest\Webtrees\Auth;
25use Fisharebest\Webtrees\Registry;
26use Fisharebest\Webtrees\I18N;
27use Fisharebest\Webtrees\Individual;
28use Fisharebest\Webtrees\Menu;
29use Fisharebest\Webtrees\Services\ChartService;
30use Fisharebest\Webtrees\Tree;
31use Fisharebest\Webtrees\Webtrees;
32use Psr\Http\Message\ResponseInterface;
33use Psr\Http\Message\ServerRequestInterface;
34use Psr\Http\Server\RequestHandlerInterface;
35
36use function app;
37use function array_keys;
38use function assert;
39use function implode;
40use function intdiv;
41use function is_string;
42use function max;
43use function min;
44use function redirect;
45use function route;
46use function str_contains;
47
48/**
49 * Class FanChartModule
50 */
51class FanChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface
52{
53    use ModuleChartTrait;
54
55    protected const ROUTE_URL  = '/tree/{tree}/fan-chart-{style}-{generations}-{width}/{xref}';
56
57    // Chart styles
58    private const STYLE_HALF_CIRCLE          = '2';
59    private const STYLE_THREE_QUARTER_CIRCLE = '3';
60    private const STYLE_FULL_CIRCLE          = '4';
61
62    // Defaults
63    private const   DEFAULT_STYLE       = self::STYLE_THREE_QUARTER_CIRCLE;
64    private const   DEFAULT_GENERATIONS = 4;
65    private const   DEFAULT_WIDTH       = 100;
66    protected const DEFAULT_PARAMETERS  = [
67        'style'       => self::DEFAULT_STYLE,
68        'generations' => self::DEFAULT_GENERATIONS,
69        'width'       => self::DEFAULT_WIDTH,
70    ];
71
72    // Limits
73    private const MINIMUM_GENERATIONS = 2;
74    private const MAXIMUM_GENERATIONS = 9;
75    private const MINIMUM_WIDTH       = 50;
76    private const MAXIMUM_WIDTH       = 500;
77
78    /** @var ChartService */
79    private $chart_service;
80
81    /**
82     * FanChartModule constructor.
83     *
84     * @param ChartService $chart_service
85     */
86    public function __construct(ChartService $chart_service)
87    {
88        $this->chart_service = $chart_service;
89    }
90
91    /**
92     * Initialization.
93     *
94     * @return void
95     */
96    public function boot(): void
97    {
98        $router_container = app(RouterContainer::class);
99        assert($router_container instanceof RouterContainer);
100
101        $router_container->getMap()
102            ->get(static::class, static::ROUTE_URL, $this)
103            ->allows(RequestMethodInterface::METHOD_POST)
104            ->tokens([
105                'generations' => '\d+',
106                'style'       => implode('|', array_keys($this->styles())),
107                'width'       => '\d+',
108            ]);
109    }
110
111    /**
112     * How should this module be identified in the control panel, etc.?
113     *
114     * @return string
115     */
116    public function title(): string
117    {
118        /* I18N: Name of a module/chart */
119        return I18N::translate('Fan chart');
120    }
121
122    /**
123     * A sentence describing what this module does.
124     *
125     * @return string
126     */
127    public function description(): string
128    {
129        /* I18N: Description of the “Fan Chart” module */
130        return I18N::translate('A fan chart of an individual’s ancestors.');
131    }
132
133    /**
134     * CSS class for the URL.
135     *
136     * @return string
137     */
138    public function chartMenuClass(): string
139    {
140        return 'menu-chart-fanchart';
141    }
142
143    /**
144     * Return a menu item for this chart - for use in individual boxes.
145     *
146     * @param Individual $individual
147     *
148     * @return Menu|null
149     */
150    public function chartBoxMenu(Individual $individual): ?Menu
151    {
152        return $this->chartMenu($individual);
153    }
154
155    /**
156     * The title for a specific instance of this chart.
157     *
158     * @param Individual $individual
159     *
160     * @return string
161     */
162    public function chartTitle(Individual $individual): string
163    {
164        /* I18N: http://en.wikipedia.org/wiki/Family_tree#Fan_chart - %s is an individual’s name */
165        return I18N::translate('Fan chart of %s', $individual->fullName());
166    }
167
168    /**
169     * A form to request the chart parameters.
170     *
171     * @param Individual $individual
172     * @param mixed[]    $parameters
173     *
174     * @return string
175     */
176    public function chartUrl(Individual $individual, array $parameters = []): string
177    {
178        return route(static::class, [
179                'xref' => $individual->xref(),
180                'tree' => $individual->tree()->name(),
181            ] + $parameters + self::DEFAULT_PARAMETERS);
182    }
183
184    /**
185     * @param ServerRequestInterface $request
186     *
187     * @return ResponseInterface
188     */
189    public function handle(ServerRequestInterface $request): ResponseInterface
190    {
191        $tree = $request->getAttribute('tree');
192        assert($tree instanceof Tree);
193
194        $user = $request->getAttribute('user');
195
196        $xref = $request->getAttribute('xref');
197        assert(is_string($xref));
198
199        $individual = Registry::individualFactory()->make($xref, $tree);
200        $individual = Auth::checkIndividualAccess($individual, false, true);
201
202        $style       = $request->getAttribute('style');
203        $generations = (int) $request->getAttribute('generations');
204        $width       = (int) $request->getAttribute('width');
205        $ajax        = $request->getQueryParams()['ajax'] ?? '';
206
207        // Convert POST requests into GET requests for pretty URLs.
208        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
209            $params = (array) $request->getParsedBody();
210
211            return redirect(route(static::class, [
212                'tree'        => $tree->name(),
213                'xref'        => $params['xref'],
214                'style'       => $params['style'],
215                'generations' => $params['generations'],
216                'width'       => $params['width'],
217            ]));
218        }
219
220        Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user);
221
222        $width = min($width, self::MAXIMUM_WIDTH);
223        $width = max($width, self::MINIMUM_WIDTH);
224
225        $generations = min($generations, self::MAXIMUM_GENERATIONS);
226        $generations = max($generations, self::MINIMUM_GENERATIONS);
227
228        if ($ajax === '1') {
229            return $this->chart($individual, $style, $width, $generations);
230        }
231
232        $ajax_url = $this->chartUrl($individual, [
233            'ajax'        => true,
234            'generations' => $generations,
235            'style'       => $style,
236            'width'       => $width,
237        ]);
238
239        return $this->viewResponse('modules/fanchart/page', [
240            'ajax_url'            => $ajax_url,
241            'generations'         => $generations,
242            'individual'          => $individual,
243            'maximum_generations' => self::MAXIMUM_GENERATIONS,
244            'minimum_generations' => self::MINIMUM_GENERATIONS,
245            'maximum_width'       => self::MAXIMUM_WIDTH,
246            'minimum_width'       => self::MINIMUM_WIDTH,
247            'module'              => $this->name(),
248            'style'               => $style,
249            'styles'              => $this->styles(),
250            'title'               => $this->chartTitle($individual),
251            'tree'                => $tree,
252            'width'               => $width,
253        ]);
254    }
255
256    /**
257     * Generate both the HTML and PNG components of the fan chart
258     *
259     * @param Individual $individual
260     * @param string     $style
261     * @param int        $width
262     * @param int        $generations
263     *
264     * @return ResponseInterface
265     */
266    protected function chart(Individual $individual, string $style, int $width, int $generations): ResponseInterface
267    {
268        $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations);
269
270        $gen  = $generations - 1;
271        $sosa = 2 ** $generations - 1;
272
273        // fan size
274        $fanw = intdiv(640 * $width, 100);
275        $cx   = $fanw / 2 - 1; // center x
276        $cy   = $cx; // center y
277        $rx   = $fanw - 1;
278        $rw   = $fanw / ($gen + 1);
279        $fanh = $fanw; // fan height
280        if ($style === self::STYLE_HALF_CIRCLE) {
281            $fanh = intdiv($fanh * ($gen + 1), $gen * 2);
282        }
283        if ($style === self::STYLE_THREE_QUARTER_CIRCLE) {
284            $fanh = intdiv($fanw * 86, 100);
285        }
286        $scale = $fanw / 640;
287
288        // Create the image
289        $image = imagecreate($fanw, $fanh);
290
291        // Create colors
292        $transparent = imagecolorallocate($image, 0, 0, 0);
293        imagecolortransparent($image, $transparent);
294
295        /** @var ModuleThemeInterface $theme */
296        $theme = app(ModuleThemeInterface::class);
297
298        $foreground = $this->imageColor($image, $theme->parameter('chart-font-color'));
299
300        $backgrounds = [
301            'M' => $this->imageColor($image, $theme->parameter('chart-background-m')),
302            'F' => $this->imageColor($image, $theme->parameter('chart-background-f')),
303            'U' => $this->imageColor($image, $theme->parameter('chart-background-u')),
304        ];
305
306        imagefilledrectangle($image, 0, 0, $fanw, $fanh, $transparent);
307
308        $fandeg = 90 * $style;
309
310        // Popup menus for each ancestor
311        $html = '';
312
313        // Areas for the imagemap
314        $areas = '';
315
316        // loop to create fan cells
317        while ($gen >= 0) {
318            // clean current generation area
319            $deg2 = 360 + ($fandeg - 180) / 2;
320            $deg1 = $deg2 - $fandeg;
321
322            // The arc size must be an even number of pixels: https://bugs.php.net/bug.php?id=79763
323            $even_rx = 2 * intdiv(1 + (int) $rx, 2);
324            imagefilledarc($image, (int) $cx, (int) $cy, $even_rx, $even_rx, (int) $deg1, (int) $deg2, $backgrounds['U'], IMG_ARC_PIE);
325            $rx -= 3;
326
327            // calculate new angle
328            $p2    = 2 ** $gen;
329            $angle = $fandeg / $p2;
330            $deg2  = 360 + ($fandeg - 180) / 2;
331            $deg1  = $deg2 - $angle;
332            // special case for rootid cell
333            if ($gen == 0) {
334                $deg1 = 90;
335                $deg2 = 360 + $deg1;
336            }
337
338            // draw each cell
339            while ($sosa >= $p2) {
340                if ($ancestors->has($sosa)) {
341                    $person  = $ancestors->get($sosa);
342                    $name    = $person->fullName();
343                    $addname = $person->alternateName();
344
345                    $text = I18N::reverseText($name);
346                    if ($addname) {
347                        $text .= "\n" . I18N::reverseText($addname);
348                    }
349
350                    $text .= "\n" . I18N::reverseText($person->lifespan());
351
352                    $background = $backgrounds[$person->sex()];
353
354                    // The arc size must be an even number of pixels: https://bugs.php.net/bug.php?id=79763
355                    $even_rx = 2 * intdiv(1 + (int) $rx, 2);
356                    imagefilledarc($image, (int) $cx, (int) $cy, $even_rx, $even_rx, (int) $deg1, (int) $deg2, $background, IMG_ARC_PIE);
357
358                    // split and center text by lines
359                    $wmax = (int) ($angle * 7 / 7 * $scale);
360                    $wmax = min($wmax, 35 * $scale);
361                    if ($gen === 0) {
362                        $wmax = min($wmax, 17 * $scale);
363                    }
364                    $text = $this->splitAlignText($text, (int) $wmax);
365
366                    // text angle
367                    $tangle = 270 - ($deg1 + $angle / 2);
368                    if ($gen === 0) {
369                        $tangle = 0;
370                    }
371
372                    // calculate text position
373                    $deg = $deg1 + 0.44;
374                    if ($deg2 - $deg1 > 40) {
375                        $deg = $deg1 + ($deg2 - $deg1) / 11;
376                    }
377                    if ($deg2 - $deg1 > 80) {
378                        $deg = $deg1 + ($deg2 - $deg1) / 7;
379                    }
380                    if ($deg2 - $deg1 > 140) {
381                        $deg = $deg1 + ($deg2 - $deg1) / 4;
382                    }
383                    if ($gen === 0) {
384                        $deg = 180;
385                    }
386                    $rad = deg2rad($deg);
387                    $mr  = ($rx - $rw / 4) / 2;
388                    if ($gen > 0 && $deg2 - $deg1 > 80) {
389                        $mr = $rx / 2;
390                    }
391                    $tx = $cx + $mr * cos($rad);
392                    $ty = $cy + $mr * sin($rad);
393                    if ($sosa === 1) {
394                        $ty -= $mr / 2;
395                    }
396
397                    // If PHP is compiled with --enable-gd-jis-conv, then the function
398                    // imagettftext() is modified to expect EUC-JP encoding instead of UTF-8.
399                    // Attempt to detect and convert...
400                    if (gd_info()['JIS-mapped Japanese Font Support'] ?? false) {
401                        $text = mb_convert_encoding($text, 'EUC-JP', 'UTF-8');
402                    }
403
404                    // print text
405                    imagettftext(
406                        $image,
407                        7,
408                        $tangle,
409                        (int) $tx,
410                        (int) $ty,
411                        $foreground,
412                        Webtrees::ROOT_DIR . 'resources/fonts/DejaVuSans.ttf',
413                        $text
414                    );
415
416                    $areas .= '<area shape="poly" coords="';
417                    // plot upper points
418                    $mr  = $rx / 2;
419                    $deg = $deg1;
420                    while ($deg <= $deg2) {
421                        $rad   = deg2rad($deg);
422                        $tx    = round($cx + $mr * cos($rad));
423                        $ty    = round($cy + $mr * sin($rad));
424                        $areas .= "$tx,$ty,";
425                        $deg   += ($deg2 - $deg1) / 6;
426                    }
427                    // plot lower points
428                    $mr  = ($rx - $rw) / 2;
429                    $deg = $deg2;
430                    while ($deg >= $deg1) {
431                        $rad   = deg2rad($deg);
432                        $tx    = round($cx + $mr * cos($rad));
433                        $ty    = round($cy + $mr * sin($rad));
434                        $areas .= "$tx,$ty,";
435                        $deg   -= ($deg2 - $deg1) / 6;
436                    }
437                    // join first point
438                    $mr    = $rx / 2;
439                    $deg   = $deg1;
440                    $rad   = deg2rad($deg);
441                    $tx    = round($cx + $mr * cos($rad));
442                    $ty    = round($cy + $mr * sin($rad));
443                    $areas .= "$tx,$ty";
444                    // add action url
445                    $areas .= '" href="#' . $person->xref() . '"';
446                    $html  .= '<div id="' . $person->xref() . '" class="fan_chart_menu">';
447                    $html  .= '<div class="person_box"><div class="small">';
448                    $html  .= '<div class="charts">';
449                    $html  .= '<a href="' . e($person->url()) . '" class="dropdown-item">' . $name . '</a>';
450                    foreach ($theme->individualBoxMenu($person) as $menu) {
451                        $html .= '<a href="' . e($menu->getLink()) . '" class="dropdown-item p-1 ' . e($menu->getClass()) . '">' . $menu->getLabel() . '</a>';
452                    }
453                    $html  .= '</div>';
454                    $html  .= '</div></div>';
455                    $html  .= '</div>';
456                    $areas .= ' alt="' . strip_tags($person->fullName()) . '" title="' . strip_tags($person->fullName()) . '">';
457                }
458                $deg1 -= $angle;
459                $deg2 -= $angle;
460                $sosa--;
461            }
462            $rx -= $rw;
463            $gen--;
464        }
465
466        ob_start();
467        imagepng($image);
468        imagedestroy($image);
469        $png = ob_get_clean();
470
471        return response(view('modules/fanchart/chart', [
472            'fanh'  => $fanh,
473            'fanw'  => $fanw,
474            'html'  => $html,
475            'areas' => $areas,
476            'png'   => $png,
477            'title' => $this->chartTitle($individual),
478        ]));
479    }
480
481    /**
482     * split and center text by lines
483     *
484     * @param string $data   input string
485     * @param int    $maxlen max length of each line
486     *
487     * @return string $text output string
488     */
489    protected function splitAlignText(string $data, int $maxlen): string
490    {
491        $RTLOrd = [
492            215,
493            216,
494            217,
495            218,
496            219,
497        ];
498
499        $lines = explode("\n", $data);
500        // more than 1 line : recursive calls
501        if (count($lines) > 1) {
502            $text = '';
503            foreach ($lines as $line) {
504                $text .= $this->splitAlignText($line, $maxlen) . "\n";
505            }
506
507            return $text;
508        }
509        // process current line word by word
510        $split = explode(' ', $data);
511        $text  = '';
512        $line  = '';
513
514        // do not split hebrew line
515        $found = false;
516        foreach ($RTLOrd as $ord) {
517            if (str_contains($data, chr($ord))) {
518                $found = true;
519            }
520        }
521        if ($found) {
522            $line = $data;
523        } else {
524            foreach ($split as $word) {
525                $len  = strlen($line);
526                $wlen = strlen($word);
527                if (($len + $wlen) < $maxlen) {
528                    if ($line !== '') {
529                        $line .= ' ';
530                    }
531                    $line .= $word;
532                } else {
533                    $p = max(0, (int) (($maxlen - $len) / 2));
534                    if ($line !== '') {
535                        $line = str_repeat(' ', $p) . $line; // center alignment using spaces
536                        $text .= $line . "\n";
537                    }
538                    $line = $word;
539                }
540            }
541        }
542        // last line
543        if ($line !== '') {
544            $len = strlen($line);
545            if (in_array(ord($line[0]), $RTLOrd, true)) {
546                $len /= 2;
547            }
548            $p    = max(0, (int) (($maxlen - $len) / 2));
549            $line = str_repeat(' ', $p) . $line; // center alignment using spaces
550            $text .= $line;
551        }
552
553        return $text;
554    }
555
556    /**
557     * Convert a CSS color into a GD color.
558     *
559     * @param resource $image
560     * @param string   $css_color
561     *
562     * @return int
563     */
564    protected function imageColor($image, string $css_color): int
565    {
566        return imagecolorallocate(
567            $image,
568            (int) hexdec(substr($css_color, 0, 2)),
569            (int) hexdec(substr($css_color, 2, 2)),
570            (int) hexdec(substr($css_color, 4, 2))
571        );
572    }
573
574    /**
575     * This chart can display its output in a number of styles
576     *
577     * @return array<string>
578     */
579    protected function styles(): array
580    {
581        return [
582            /* I18N: layout option for the fan chart */
583            self::STYLE_HALF_CIRCLE          => I18N::translate('half circle'),
584            /* I18N: layout option for the fan chart */
585            self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'),
586            /* I18N: layout option for the fan chart */
587            self::STYLE_FULL_CIRCLE          => I18N::translate('full circle'),
588        ];
589    }
590}
591