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