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