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