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