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