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