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