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