Passed
Push — master ( 01202f...713784 )
by Greg
07:38
created

FanChartModule::splitAlignText()   C

Complexity

Conditions 12
Paths 20

Size

Total Lines 65
Code Lines 43

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 43
nc 20
nop 2
dl 0
loc 65
rs 6.9666
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
declare(strict_types=1);
18
19
namespace Fisharebest\Webtrees\Module;
20
21
use Aura\Router\RouterContainer;
22
use Fig\Http\Message\RequestMethodInterface;
23
use Fisharebest\Webtrees\Auth;
24
use Fisharebest\Webtrees\I18N;
25
use Fisharebest\Webtrees\Individual;
26
use Fisharebest\Webtrees\Menu;
27
use Fisharebest\Webtrees\Services\ChartService;
28
use Fisharebest\Webtrees\Webtrees;
29
use Psr\Http\Message\ResponseInterface;
30
use Psr\Http\Message\ServerRequestInterface;
31
use Psr\Http\Server\RequestHandlerInterface;
32
33
use function array_keys;
34
use function implode;
35
use function max;
36
use function min;
37
use function redirect;
38
use function route;
39
40
/**
41
 * Class FanChartModule
42
 */
43
class FanChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface
44
{
45
    use ModuleChartTrait;
46
47
    private const ROUTE_NAME = 'fan-chart';
48
    private const ROUTE_URL  = '/tree/{tree}/fan-chart-{style}-{generations}-{width}/{xref}';
49
50
    // Chart styles
51
    private const STYLE_HALF_CIRCLE          = '2';
52
    private const STYLE_THREE_QUARTER_CIRCLE = '3';
53
    private const STYLE_FULL_CIRCLE          = '4';
54
55
    // Defaults
56
    private const   DEFAULT_STYLE       = self::STYLE_THREE_QUARTER_CIRCLE;
57
    private const   DEFAULT_GENERATIONS = 4;
58
    private const   DEFAULT_WIDTH       = 100;
59
    protected const DEFAULT_PARAMETERS  = [
60
        'style'       => self::DEFAULT_STYLE,
61
        'generations' => self::DEFAULT_GENERATIONS,
62
        'width'       => self::DEFAULT_WIDTH,
63
    ];
64
65
    // Limits
66
    private const MINIMUM_GENERATIONS = 2;
67
    private const MAXIMUM_GENERATIONS = 9;
68
    private const MINIMUM_WIDTH       = 50;
69
    private const MAXIMUM_WIDTH       = 500;
70
71
    /** @var ChartService */
72
    private $chart_service;
73
74
    /**
75
     * FanChartModule constructor.
76
     *
77
     * @param ChartService $chart_service
78
     */
79
    public function __construct(ChartService $chart_service)
80
    {
81
        $this->chart_service = $chart_service;
82
    }
83
84
    /**
85
     * Initialization.
86
     *
87
     * @param RouterContainer $router_container
88
     */
89
    public function boot(RouterContainer $router_container)
90
    {
91
        $router_container->getMap()
92
            ->get(self::ROUTE_NAME, self::ROUTE_URL, self::class)
93
            ->allows(RequestMethodInterface::METHOD_POST)
94
            ->tokens([
95
                'generations' => '\d+',
96
                'style'       => implode('|', array_keys($this->styles())),
97
                'width'       => '\d+',
98
            ]);
99
    }
100
101
    /**
102
     * How should this module be identified in the control panel, etc.?
103
     *
104
     * @return string
105
     */
106
    public function title(): string
107
    {
108
        /* I18N: Name of a module/chart */
109
        return I18N::translate('Fan chart');
110
    }
111
112
    /**
113
     * A sentence describing what this module does.
114
     *
115
     * @return string
116
     */
117
    public function description(): string
118
    {
119
        /* I18N: Description of the “Fan Chart” module */
120
        return I18N::translate('A fan chart of an individual’s ancestors.');
121
    }
122
123
    /**
124
     * CSS class for the URL.
125
     *
126
     * @return string
127
     */
128
    public function chartMenuClass(): string
129
    {
130
        return 'menu-chart-fanchart';
131
    }
132
133
    /**
134
     * Return a menu item for this chart - for use in individual boxes.
135
     *
136
     * @param Individual $individual
137
     *
138
     * @return Menu|null
139
     */
140
    public function chartBoxMenu(Individual $individual): ?Menu
141
    {
142
        return $this->chartMenu($individual);
143
    }
144
145
    /**
146
     * The title for a specific instance of this chart.
147
     *
148
     * @param Individual $individual
149
     *
150
     * @return string
151
     */
152
    public function chartTitle(Individual $individual): string
153
    {
154
        /* I18N: http://en.wikipedia.org/wiki/Family_tree#Fan_chart - %s is an individual’s name */
155
        return I18N::translate('Fan chart of %s', $individual->fullName());
156
    }
157
158
    /**
159
     * A form to request the chart parameters.
160
     *
161
     * @param Individual $individual
162
     * @param string[]   $parameters
163
     *
164
     * @return string
165
     */
166
    public function chartUrl(Individual $individual, array $parameters = []): string
167
    {
168
        return route(self::ROUTE_NAME, [
169
                'xref' => $individual->xref(),
170
                'tree' => $individual->tree()->name(),
171
            ] + $parameters + self::DEFAULT_PARAMETERS);
172
    }
173
174
    /**
175
     * @param ServerRequestInterface $request
176
     *
177
     * @return ResponseInterface
178
     */
179
    public function handle(ServerRequestInterface $request): ResponseInterface
180
    {
181
        $tree        = $request->getAttribute('tree');
182
        $user        = $request->getAttribute('user');
183
        $xref        = $request->getAttribute('xref');
184
        $style       = $request->getAttribute('style');
185
        $generations = (int) $request->getAttribute('generations');
186
        $width       = (int) $request->getAttribute('width');
187
        $ajax        = $request->getQueryParams()['ajax'] ?? '';
188
        $individual  = Individual::getInstance($xref, $tree);
189
190
        // Convert POST requests into GET requests for pretty URLs.
191
        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
192
            return redirect(route(self::ROUTE_NAME, [
193
                'tree'        => $request->getAttribute('tree')->name(),
194
                'xref'        => $request->getParsedBody()['xref'],
195
                'style'       => $request->getParsedBody()['style'],
196
                'generations' => $request->getParsedBody()['generations'],
197
                'width'       => $request->getParsedBody()['width'],
198
            ]));
199
        }
200
201
        Auth::checkIndividualAccess($individual);
202
        Auth::checkComponentAccess($this, 'chart', $tree, $user);
203
204
        $width = min($width, self::MAXIMUM_WIDTH);
205
        $width = max($width, self::MINIMUM_WIDTH);
206
207
        $generations = min($generations, self::MAXIMUM_GENERATIONS);
208
        $generations = max($generations, self::MINIMUM_GENERATIONS);
209
210
        if ($ajax === '1') {
211
            return $this->chart($individual, $style, $width, $generations);
212
        }
213
214
        $ajax_url = $this->chartUrl($individual, [
215
            'ajax'        => true,
216
            'generations' => $generations,
217
            'style'       => $style,
218
            'width'       => $width,
219
        ]);
220
221
        return $this->viewResponse('modules/fanchart/page', [
222
            'ajax_url'            => $ajax_url,
223
            'generations'         => $generations,
224
            'individual'          => $individual,
225
            'maximum_generations' => self::MAXIMUM_GENERATIONS,
226
            'minimum_generations' => self::MINIMUM_GENERATIONS,
227
            'maximum_width'       => self::MAXIMUM_WIDTH,
228
            'minimum_width'       => self::MINIMUM_WIDTH,
229
            'module'              => $this->name(),
230
            'style'               => $style,
231
            'styles'              => $this->styles(),
232
            'title'               => $this->chartTitle($individual),
233
            'width'               => $width,
234
        ]);
235
    }
236
237
    /**
238
     * Generate both the HTML and PNG components of the fan chart
239
     *
240
     * @param Individual $individual
241
     * @param string     $style
242
     * @param int        $width
243
     * @param int        $generations
244
     *
245
     * @return ResponseInterface
246
     */
247
    protected function chart(Individual $individual, string $style, int $width, int $generations): ResponseInterface
248
    {
249
        $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations);
250
251
        $gen  = $generations - 1;
252
        $sosa = 2 ** $generations - 1;
253
254
        // fan size
255
        $fanw = 640 * $width / 100;
256
        $cx   = $fanw / 2 - 1; // center x
257
        $cy   = $cx; // center y
258
        $rx   = $fanw - 1;
259
        $rw   = $fanw / ($gen + 1);
260
        $fanh = $fanw; // fan height
261
        if ($style === self::STYLE_HALF_CIRCLE) {
262
            $fanh = $fanh * ($gen + 1) / ($gen * 2);
263
        }
264
        if ($style === self::STYLE_THREE_QUARTER_CIRCLE) {
265
            $fanh *= 0.86;
266
        }
267
        $scale = $fanw / 640;
268
269
        // Create the image
270
        $image = imagecreate((int) $fanw, (int) $fanh);
271
272
        // Create colors
273
        $transparent = imagecolorallocate($image, 0, 0, 0);
0 ignored issues
show
Bug introduced by
It seems like $image can also be of type false; however, parameter $image of imagecolorallocate() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

273
        $transparent = imagecolorallocate(/** @scrutinizer ignore-type */ $image, 0, 0, 0);
Loading history...
274
        imagecolortransparent($image, $transparent);
0 ignored issues
show
Bug introduced by
It seems like $image can also be of type false; however, parameter $image of imagecolortransparent() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

274
        imagecolortransparent(/** @scrutinizer ignore-type */ $image, $transparent);
Loading history...
275
276
        $theme = app(ModuleThemeInterface::class);
277
278
        $foreground = $this->imageColor($image, $theme->parameter('chart-font-color'));
0 ignored issues
show
Bug introduced by
It seems like $image can also be of type false; however, parameter $image of Fisharebest\Webtrees\Mod...artModule::imageColor() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

278
        $foreground = $this->imageColor(/** @scrutinizer ignore-type */ $image, $theme->parameter('chart-font-color'));
Loading history...
279
280
        $backgrounds = [
281
            'M' => $this->imageColor($image, $theme->parameter('chart-background-m')),
282
            'F' => $this->imageColor($image, $theme->parameter('chart-background-f')),
283
            'U' => $this->imageColor($image, $theme->parameter('chart-background-u')),
284
        ];
285
286
        imagefilledrectangle($image, 0, 0, (int) $fanw, (int) $fanh, $transparent);
0 ignored issues
show
Bug introduced by
It seems like $image can also be of type false; however, parameter $image of imagefilledrectangle() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

286
        imagefilledrectangle(/** @scrutinizer ignore-type */ $image, 0, 0, (int) $fanw, (int) $fanh, $transparent);
Loading history...
287
288
        $fandeg = 90 * $style;
289
290
        // Popup menus for each ancestor
291
        $html = '';
292
293
        // Areas for the imagemap
294
        $areas = '';
295
296
        // loop to create fan cells
297
        while ($gen >= 0) {
298
            // clean current generation area
299
            $deg2 = 360 + ($fandeg - 180) / 2;
300
            $deg1 = $deg2 - $fandeg;
301
            imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $backgrounds['U'], IMG_ARC_PIE);
0 ignored issues
show
Bug introduced by
It seems like $image can also be of type false; however, parameter $image of imagefilledarc() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

301
            imagefilledarc(/** @scrutinizer ignore-type */ $image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $backgrounds['U'], IMG_ARC_PIE);
Loading history...
302
            $rx -= 3;
303
304
            // calculate new angle
305
            $p2    = 2 ** $gen;
306
            $angle = $fandeg / $p2;
307
            $deg2  = 360 + ($fandeg - 180) / 2;
308
            $deg1  = $deg2 - $angle;
309
            // special case for rootid cell
310
            if ($gen == 0) {
311
                $deg1 = 90;
312
                $deg2 = 360 + $deg1;
313
            }
314
315
            // draw each cell
316
            while ($sosa >= $p2) {
317
                if ($ancestors->has($sosa)) {
318
                    $person  = $ancestors->get($sosa);
319
                    $name    = $person->fullName();
320
                    $addname = $person->alternateName();
321
322
                    $text = I18N::reverseText($name);
323
                    if ($addname) {
324
                        $text .= "\n" . I18N::reverseText($addname);
325
                    }
326
327
                    $text .= "\n" . I18N::reverseText($person->getLifeSpan());
328
329
                    $background = $backgrounds[$person->sex()];
330
331
                    imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $background, IMG_ARC_PIE);
332
333
                    // split and center text by lines
334
                    $wmax = (int) ($angle * 7 / 7 * $scale);
335
                    $wmax = min($wmax, 35 * $scale);
336
                    if ($gen === 0) {
337
                        $wmax = min($wmax, 17 * $scale);
338
                    }
339
                    $text = $this->splitAlignText($text, (int) $wmax);
340
341
                    // text angle
342
                    $tangle = 270 - ($deg1 + $angle / 2);
343
                    if ($gen === 0) {
344
                        $tangle = 0;
345
                    }
346
347
                    // calculate text position
348
                    $deg = $deg1 + 0.44;
349
                    if ($deg2 - $deg1 > 40) {
350
                        $deg = $deg1 + ($deg2 - $deg1) / 11;
351
                    }
352
                    if ($deg2 - $deg1 > 80) {
353
                        $deg = $deg1 + ($deg2 - $deg1) / 7;
354
                    }
355
                    if ($deg2 - $deg1 > 140) {
356
                        $deg = $deg1 + ($deg2 - $deg1) / 4;
357
                    }
358
                    if ($gen === 0) {
359
                        $deg = 180;
360
                    }
361
                    $rad = deg2rad($deg);
362
                    $mr  = ($rx - $rw / 4) / 2;
363
                    if ($gen > 0 && $deg2 - $deg1 > 80) {
364
                        $mr = $rx / 2;
365
                    }
366
                    $tx = $cx + $mr * cos($rad);
367
                    $ty = $cy + $mr * sin($rad);
368
                    if ($sosa === 1) {
369
                        $ty -= $mr / 2;
370
                    }
371
372
                    // print text
373
                    imagettftext(
374
                        $image,
0 ignored issues
show
Bug introduced by
It seems like $image can also be of type false; however, parameter $image of imagettftext() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

374
                        /** @scrutinizer ignore-type */ $image,
Loading history...
375
                        7,
376
                        $tangle,
377
                        (int) $tx,
378
                        (int) $ty,
379
                        $foreground,
380
                        Webtrees::ROOT_DIR . 'resources/fonts/DejaVuSans.ttf',
381
                        $text
382
                    );
383
384
                    $areas .= '<area shape="poly" coords="';
385
                    // plot upper points
386
                    $mr  = $rx / 2;
387
                    $deg = $deg1;
388
                    while ($deg <= $deg2) {
389
                        $rad   = deg2rad($deg);
390
                        $tx    = round($cx + $mr * cos($rad));
391
                        $ty    = round($cy + $mr * sin($rad));
392
                        $areas .= "$tx,$ty,";
393
                        $deg   += ($deg2 - $deg1) / 6;
394
                    }
395
                    // plot lower points
396
                    $mr  = ($rx - $rw) / 2;
397
                    $deg = $deg2;
398
                    while ($deg >= $deg1) {
399
                        $rad   = deg2rad($deg);
400
                        $tx    = round($cx + $mr * cos($rad));
401
                        $ty    = round($cy + $mr * sin($rad));
402
                        $areas .= "$tx,$ty,";
403
                        $deg   -= ($deg2 - $deg1) / 6;
404
                    }
405
                    // join first point
406
                    $mr    = $rx / 2;
407
                    $deg   = $deg1;
408
                    $rad   = deg2rad($deg);
409
                    $tx    = round($cx + $mr * cos($rad));
410
                    $ty    = round($cy + $mr * sin($rad));
411
                    $areas .= "$tx,$ty";
412
                    // add action url
413
                    $areas .= '" href="#' . $person->xref() . '"';
414
                    $html  .= '<div id="' . $person->xref() . '" class="fan_chart_menu">';
415
                    $html  .= '<div class="person_box"><div class="details1">';
416
                    $html  .= '<div class="charts">';
417
                    $html  .= '<a href="' . e($person->url()) . '" class="dropdown-item">' . $name . '</a>';
418
                    foreach ($theme->individualBoxMenu($person) as $menu) {
419
                        $html .= '<a href="' . e($menu->getLink()) . '" class="dropdown-item p-1 ' . e($menu->getClass()) . '">' . $menu->getLabel() . '</a>';
420
                    }
421
                    $html  .= '</div>';
422
                    $html  .= '</div></div>';
423
                    $html  .= '</div>';
424
                    $areas .= ' alt="' . strip_tags($person->fullName()) . '" title="' . strip_tags($person->fullName()) . '">';
425
                }
426
                $deg1 -= $angle;
427
                $deg2 -= $angle;
428
                $sosa--;
429
            }
430
            $rx -= $rw;
431
            $gen--;
432
        }
433
434
        ob_start();
435
        imagepng($image);
0 ignored issues
show
Bug introduced by
It seems like $image can also be of type false; however, parameter $image of imagepng() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

435
        imagepng(/** @scrutinizer ignore-type */ $image);
Loading history...
436
        imagedestroy($image);
0 ignored issues
show
Bug introduced by
It seems like $image can also be of type false; however, parameter $image of imagedestroy() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

436
        imagedestroy(/** @scrutinizer ignore-type */ $image);
Loading history...
437
        $png = ob_get_clean();
438
439
        return response(view('modules/fanchart/chart', [
440
            'fanh'  => $fanh,
441
            'fanw'  => $fanw,
442
            'html'  => $html,
443
            'areas' => $areas,
444
            'png'   => $png,
445
            'title' => $this->chartTitle($individual),
446
        ]));
447
    }
448
449
    /**
450
     * split and center text by lines
451
     *
452
     * @param string $data   input string
453
     * @param int    $maxlen max length of each line
454
     *
455
     * @return string $text output string
456
     */
457
    protected function splitAlignText(string $data, int $maxlen): string
458
    {
459
        $RTLOrd = [
460
            215,
461
            216,
462
            217,
463
            218,
464
            219,
465
        ];
466
467
        $lines = explode("\n", $data);
468
        // more than 1 line : recursive calls
469
        if (count($lines) > 1) {
470
            $text = '';
471
            foreach ($lines as $line) {
472
                $text .= $this->splitAlignText($line, $maxlen) . "\n";
473
            }
474
475
            return $text;
476
        }
477
        // process current line word by word
478
        $split = explode(' ', $data);
479
        $text  = '';
480
        $line  = '';
481
482
        // do not split hebrew line
483
        $found = false;
484
        foreach ($RTLOrd as $ord) {
485
            if (strpos($data, chr($ord)) !== false) {
486
                $found = true;
487
            }
488
        }
489
        if ($found) {
490
            $line = $data;
491
        } else {
492
            foreach ($split as $word) {
493
                $len  = strlen($line);
494
                $wlen = strlen($word);
495
                if (($len + $wlen) < $maxlen) {
496
                    if (!empty($line)) {
497
                        $line .= ' ';
498
                    }
499
                    $line .= $word;
500
                } else {
501
                    $p = max(0, (int) (($maxlen - $len) / 2));
502
                    if (!empty($line)) {
503
                        $line = str_repeat(' ', $p) . $line; // center alignment using spaces
504
                        $text .= $line . "\n";
505
                    }
506
                    $line = $word;
507
                }
508
            }
509
        }
510
        // last line
511
        if (!empty($line)) {
512
            $len = strlen($line);
513
            if (in_array(ord($line[0]), $RTLOrd, true)) {
514
                $len /= 2;
515
            }
516
            $p    = max(0, (int) (($maxlen - $len) / 2));
517
            $line = str_repeat(' ', $p) . $line; // center alignment using spaces
518
            $text .= $line;
519
        }
520
521
        return $text;
522
    }
523
524
    /**
525
     * Convert a CSS color into a GD color.
526
     *
527
     * @param resource $image
528
     * @param string   $css_color
529
     *
530
     * @return int
531
     */
532
    protected function imageColor($image, string $css_color): int
533
    {
534
        return imagecolorallocate(
535
            $image,
536
            (int) hexdec(substr($css_color, 0, 2)),
537
            (int) hexdec(substr($css_color, 2, 2)),
538
            (int) hexdec(substr($css_color, 4, 2))
539
        );
540
    }
541
542
    /**
543
     * This chart can display its output in a number of styles
544
     *
545
     * @return array
546
     */
547
    protected function styles(): array
548
    {
549
        return [
550
            /* I18N: layout option for the fan chart */
551
            self::STYLE_HALF_CIRCLE          => I18N::translate('half circle'),
552
            /* I18N: layout option for the fan chart */
553
            self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'),
554
            /* I18N: layout option for the fan chart */
555
            self::STYLE_FULL_CIRCLE          => I18N::translate('full circle'),
556
        ];
557
    }
558
}
559