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