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