1168ff6f3Sric2016<?php 23976b470SGreg Roach 3168ff6f3Sric2016/** 4168ff6f3Sric2016 * webtrees: online genealogy 55bfc6897SGreg Roach * Copyright (C) 2022 webtrees development team 6168ff6f3Sric2016 * This program is free software: you can redistribute it and/or modify 7168ff6f3Sric2016 * it under the terms of the GNU General Public License as published by 8168ff6f3Sric2016 * the Free Software Foundation, either version 3 of the License, or 9168ff6f3Sric2016 * (at your option) any later version. 10168ff6f3Sric2016 * This program is distributed in the hope that it will be useful, 11168ff6f3Sric2016 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12168ff6f3Sric2016 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13168ff6f3Sric2016 * GNU General Public License for more details. 14168ff6f3Sric2016 * You should have received a copy of the GNU General Public License 1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>. 16168ff6f3Sric2016 */ 17fcfa147eSGreg Roach 18e7f56f2aSGreg Roachdeclare(strict_types=1); 19e7f56f2aSGreg Roach 20168ff6f3Sric2016namespace Fisharebest\Webtrees\Module; 21168ff6f3Sric2016 2271378461SGreg Roachuse Aura\Router\RouterContainer; 2371378461SGreg Roachuse Fig\Http\Message\RequestMethodInterface; 24389266c0SGreg Roachuse Fisharebest\Webtrees\Auth; 25168ff6f3Sric2016use Fisharebest\Webtrees\I18N; 26168ff6f3Sric2016use Fisharebest\Webtrees\Individual; 276664b4a3SGreg Roachuse Fisharebest\Webtrees\Menu; 2891bb35acSGreg Roachuse Fisharebest\Webtrees\Registry; 29389266c0SGreg Roachuse Fisharebest\Webtrees\Services\ChartService; 30b55cbc6bSGreg Roachuse Fisharebest\Webtrees\Validator; 31f397d0fdSGreg Roachuse Fisharebest\Webtrees\Webtrees; 326ccdf4f0SGreg Roachuse Psr\Http\Message\ResponseInterface; 336ccdf4f0SGreg Roachuse Psr\Http\Message\ServerRequestInterface; 3471378461SGreg Roachuse Psr\Http\Server\RequestHandlerInterface; 3571378461SGreg Roach 369e18e23bSGreg Roachuse function app; 3791bb35acSGreg Roachuse function array_filter; 3871378461SGreg Roachuse function array_keys; 3991bb35acSGreg Roachuse function array_map; 409e18e23bSGreg Roachuse function assert; 4191bb35acSGreg Roachuse function cos; 4291bb35acSGreg Roachuse function deg2rad; 4391bb35acSGreg Roachuse function e; 4491bb35acSGreg Roachuse function gd_info; 4591bb35acSGreg Roachuse function hexdec; 4691bb35acSGreg Roachuse function imagecolorallocate; 4791bb35acSGreg Roachuse function imagecolortransparent; 4891bb35acSGreg Roachuse function imagecreate; 4991bb35acSGreg Roachuse function imagedestroy; 5091bb35acSGreg Roachuse function imagefilledarc; 5191bb35acSGreg Roachuse function imagefilledrectangle; 5291bb35acSGreg Roachuse function imagepng; 5391bb35acSGreg Roachuse function imagettfbbox; 5491bb35acSGreg Roachuse function imagettftext; 5571378461SGreg Roachuse function implode; 56a18dc098SGreg Roachuse function intdiv; 5771378461SGreg Roachuse function max; 5891bb35acSGreg Roachuse function mb_substr; 5971378461SGreg Roachuse function min; 6091bb35acSGreg Roachuse function ob_get_clean; 6191bb35acSGreg Roachuse function ob_start; 6271378461SGreg Roachuse function redirect; 6391bb35acSGreg Roachuse function response; 6491bb35acSGreg Roachuse function round; 6571378461SGreg Roachuse function route; 6691bb35acSGreg Roachuse function rtrim; 6791bb35acSGreg Roachuse function sin; 6891bb35acSGreg Roachuse function sqrt; 6991bb35acSGreg Roachuse function strip_tags; 7091bb35acSGreg Roachuse function substr; 7191bb35acSGreg Roachuse function view; 7291bb35acSGreg Roach 7391bb35acSGreg Roachuse const IMG_ARC_PIE; 74168ff6f3Sric2016 75168ff6f3Sric2016/** 76168ff6f3Sric2016 * Class FanChartModule 77168ff6f3Sric2016 */ 7871378461SGreg Roachclass FanChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface 79c1010edaSGreg Roach{ 8049a243cbSGreg Roach use ModuleChartTrait; 8149a243cbSGreg Roach 8272f04adfSGreg Roach protected const ROUTE_URL = '/tree/{tree}/fan-chart-{style}-{generations}-{width}/{xref}'; 8371378461SGreg Roach 84389266c0SGreg Roach // Chart styles 85*d2d056daSGreg Roach private const STYLE_HALF_CIRCLE = 2; 86*d2d056daSGreg Roach private const STYLE_THREE_QUARTER_CIRCLE = 3; 87*d2d056daSGreg Roach private const STYLE_FULL_CIRCLE = 4; 8871378461SGreg Roach 8971378461SGreg Roach // Defaults 9071378461SGreg Roach private const DEFAULT_STYLE = self::STYLE_THREE_QUARTER_CIRCLE; 9171378461SGreg Roach private const DEFAULT_GENERATIONS = 4; 9271378461SGreg Roach private const DEFAULT_WIDTH = 100; 9371378461SGreg Roach protected const DEFAULT_PARAMETERS = [ 9471378461SGreg Roach 'style' => self::DEFAULT_STYLE, 9571378461SGreg Roach 'generations' => self::DEFAULT_GENERATIONS, 9671378461SGreg Roach 'width' => self::DEFAULT_WIDTH, 9771378461SGreg Roach ]; 98389266c0SGreg Roach 99389266c0SGreg Roach // Limits 100389266c0SGreg Roach private const MINIMUM_GENERATIONS = 2; 101389266c0SGreg Roach private const MAXIMUM_GENERATIONS = 9; 102389266c0SGreg Roach private const MINIMUM_WIDTH = 50; 103389266c0SGreg Roach private const MAXIMUM_WIDTH = 500; 104389266c0SGreg Roach 10591bb35acSGreg Roach // Chart layout parameters 10691bb35acSGreg Roach private const FONT = Webtrees::ROOT_DIR . 'resources/fonts/DejaVuSans.ttf'; 10791bb35acSGreg Roach private const CHART_WIDTH_PIXELS = 800; 10891bb35acSGreg Roach private const TEXT_SIZE_POINTS = self::CHART_WIDTH_PIXELS / 120.0; 10991bb35acSGreg Roach private const GAP_BETWEEN_RINGS = 2; 11091bb35acSGreg Roach 11191bb35acSGreg Roach private ChartService $chart_service; 11257ab2231SGreg Roach 11357ab2231SGreg Roach /** 11457ab2231SGreg Roach * FanChartModule constructor. 11557ab2231SGreg Roach * 11657ab2231SGreg Roach * @param ChartService $chart_service 11757ab2231SGreg Roach */ 1183976b470SGreg Roach public function __construct(ChartService $chart_service) 1193976b470SGreg Roach { 12057ab2231SGreg Roach $this->chart_service = $chart_service; 12157ab2231SGreg Roach } 12257ab2231SGreg Roach 123168ff6f3Sric2016 /** 12471378461SGreg Roach * Initialization. 12571378461SGreg Roach * 1269e18e23bSGreg Roach * @return void 12771378461SGreg Roach */ 1289e18e23bSGreg Roach public function boot(): void 12971378461SGreg Roach { 130158900c2SGreg Roach Registry::routeFactory()->routeMap() 13172f04adfSGreg Roach ->get(static::class, static::ROUTE_URL, $this) 132158900c2SGreg Roach ->allows(RequestMethodInterface::METHOD_POST); 13371378461SGreg Roach } 13471378461SGreg Roach 13571378461SGreg Roach /** 1360cfd6963SGreg Roach * How should this module be identified in the control panel, etc.? 137168ff6f3Sric2016 * 138168ff6f3Sric2016 * @return string 139168ff6f3Sric2016 */ 14049a243cbSGreg Roach public function title(): string 141c1010edaSGreg Roach { 142bbb76c12SGreg Roach /* I18N: Name of a module/chart */ 143bbb76c12SGreg Roach return I18N::translate('Fan chart'); 144168ff6f3Sric2016 } 145168ff6f3Sric2016 146168ff6f3Sric2016 /** 147168ff6f3Sric2016 * A sentence describing what this module does. 148168ff6f3Sric2016 * 149168ff6f3Sric2016 * @return string 150168ff6f3Sric2016 */ 15149a243cbSGreg Roach public function description(): string 152c1010edaSGreg Roach { 153bbb76c12SGreg Roach /* I18N: Description of the “Fan Chart” module */ 154bbb76c12SGreg Roach return I18N::translate('A fan chart of an individual’s ancestors.'); 155168ff6f3Sric2016 } 156168ff6f3Sric2016 157168ff6f3Sric2016 /** 158377a2979SGreg Roach * CSS class for the URL. 159377a2979SGreg Roach * 160377a2979SGreg Roach * @return string 161377a2979SGreg Roach */ 162377a2979SGreg Roach public function chartMenuClass(): string 163377a2979SGreg Roach { 164377a2979SGreg Roach return 'menu-chart-fanchart'; 165377a2979SGreg Roach } 166377a2979SGreg Roach 167377a2979SGreg Roach /** 1684eb71cfaSGreg Roach * Return a menu item for this chart - for use in individual boxes. 1694eb71cfaSGreg Roach * 17060bc3e3fSGreg Roach * @param Individual $individual 17160bc3e3fSGreg Roach * 1724eb71cfaSGreg Roach * @return Menu|null 1734eb71cfaSGreg Roach */ 174377a2979SGreg Roach public function chartBoxMenu(Individual $individual): ?Menu 175c1010edaSGreg Roach { 176e6562982SGreg Roach return $this->chartMenu($individual); 177e6562982SGreg Roach } 178e6562982SGreg Roach 179e6562982SGreg Roach /** 180e6562982SGreg Roach * The title for a specific instance of this chart. 181e6562982SGreg Roach * 182e6562982SGreg Roach * @param Individual $individual 183e6562982SGreg Roach * 184e6562982SGreg Roach * @return string 185e6562982SGreg Roach */ 186e6562982SGreg Roach public function chartTitle(Individual $individual): string 187e6562982SGreg Roach { 188ad3143ccSGreg Roach /* I18N: https://en.wikipedia.org/wiki/Family_tree#Fan_chart - %s is an individual’s name */ 18939ca88baSGreg Roach return I18N::translate('Fan chart of %s', $individual->fullName()); 190e6562982SGreg Roach } 191e6562982SGreg Roach 192e6562982SGreg Roach /** 193389266c0SGreg Roach * A form to request the chart parameters. 194389266c0SGreg Roach * 19571378461SGreg Roach * @param Individual $individual 19676d39c55SGreg Roach * @param array<bool|int|string|array<string>|null> $parameters 19771378461SGreg Roach * 19871378461SGreg Roach * @return string 19971378461SGreg Roach */ 20071378461SGreg Roach public function chartUrl(Individual $individual, array $parameters = []): string 20171378461SGreg Roach { 20272f04adfSGreg Roach return route(static::class, [ 20371378461SGreg Roach 'xref' => $individual->xref(), 20471378461SGreg Roach 'tree' => $individual->tree()->name(), 20571378461SGreg Roach ] + $parameters + self::DEFAULT_PARAMETERS); 20671378461SGreg Roach } 20771378461SGreg Roach 20871378461SGreg Roach /** 2096ccdf4f0SGreg Roach * @param ServerRequestInterface $request 210389266c0SGreg Roach * 2116ccdf4f0SGreg Roach * @return ResponseInterface 212389266c0SGreg Roach */ 21371378461SGreg Roach public function handle(ServerRequestInterface $request): ResponseInterface 214389266c0SGreg Roach { 215b55cbc6bSGreg Roach $tree = Validator::attributes($request)->tree(); 216b55cbc6bSGreg Roach $user = Validator::attributes($request)->user(); 217b55cbc6bSGreg Roach $xref = Validator::attributes($request)->isXref()->string('xref'); 218*d2d056daSGreg Roach $style = Validator::attributes($request)->isInArrayKeys($this->styles())->integer('style'); 219b55cbc6bSGreg Roach $generations = Validator::attributes($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'); 220b55cbc6bSGreg Roach $width = Validator::attributes($request)->isBetween(self::MINIMUM_WIDTH, self::MAXIMUM_WIDTH)->integer('width'); 221b55cbc6bSGreg Roach $ajax = Validator::queryParams($request)->boolean('ajax', false); 222389266c0SGreg Roach 22371378461SGreg Roach // Convert POST requests into GET requests for pretty URLs. 22471378461SGreg Roach if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 22572f04adfSGreg Roach return redirect(route(static::class, [ 2264ea62551SGreg Roach 'tree' => $tree->name(), 227158900c2SGreg Roach 'generations' => Validator::parsedBody($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'), 228*d2d056daSGreg Roach 'style' => Validator::parsedBody($request)->isInArrayKeys($this->styles())->integer('style'), 229158900c2SGreg Roach 'width' => Validator::parsedBody($request)->isBetween(self::MINIMUM_WIDTH, self::MAXIMUM_WIDTH)->integer('width'), 230158900c2SGreg Roach 'xref' => Validator::parsedBody($request)->isXref()->string('xref'), 23171378461SGreg Roach ])); 23271378461SGreg Roach } 23371378461SGreg Roach 234ef483801SGreg Roach Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); 235389266c0SGreg Roach 236b55cbc6bSGreg Roach $individual = Registry::individualFactory()->make($xref, $tree); 237b55cbc6bSGreg Roach $individual = Auth::checkIndividualAccess($individual, false, true); 238389266c0SGreg Roach 239b55cbc6bSGreg Roach if ($ajax) { 24071378461SGreg Roach return $this->chart($individual, $style, $width, $generations); 241389266c0SGreg Roach } 242389266c0SGreg Roach 243389266c0SGreg Roach $ajax_url = $this->chartUrl($individual, [ 2449b5537c3SGreg Roach 'ajax' => true, 245389266c0SGreg Roach 'generations' => $generations, 24671378461SGreg Roach 'style' => $style, 24771378461SGreg Roach 'width' => $width, 248389266c0SGreg Roach ]); 249389266c0SGreg Roach 2509b5537c3SGreg Roach return $this->viewResponse('modules/fanchart/page', [ 251389266c0SGreg Roach 'ajax_url' => $ajax_url, 252389266c0SGreg Roach 'generations' => $generations, 253389266c0SGreg Roach 'individual' => $individual, 254389266c0SGreg Roach 'maximum_generations' => self::MAXIMUM_GENERATIONS, 255389266c0SGreg Roach 'minimum_generations' => self::MINIMUM_GENERATIONS, 256389266c0SGreg Roach 'maximum_width' => self::MAXIMUM_WIDTH, 257389266c0SGreg Roach 'minimum_width' => self::MINIMUM_WIDTH, 25871378461SGreg Roach 'module' => $this->name(), 25971378461SGreg Roach 'style' => $style, 26071378461SGreg Roach 'styles' => $this->styles(), 261389266c0SGreg Roach 'title' => $this->chartTitle($individual), 262ef5d23f1SGreg Roach 'tree' => $tree, 26371378461SGreg Roach 'width' => $width, 264389266c0SGreg Roach ]); 265389266c0SGreg Roach } 266389266c0SGreg Roach 267389266c0SGreg Roach /** 268389266c0SGreg Roach * Generate both the HTML and PNG components of the fan chart 269e6562982SGreg Roach * 270e6562982SGreg Roach * @param Individual $individual 271*d2d056daSGreg Roach * @param int $style 27271378461SGreg Roach * @param int $width 273389266c0SGreg Roach * @param int $generations 274e6562982SGreg Roach * 2756ccdf4f0SGreg Roach * @return ResponseInterface 276e6562982SGreg Roach */ 277*d2d056daSGreg Roach protected function chart(Individual $individual, int $style, int $width, int $generations): ResponseInterface 278e6562982SGreg Roach { 27971378461SGreg Roach $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations); 280389266c0SGreg Roach 28191bb35acSGreg Roach $width = intdiv(self::CHART_WIDTH_PIXELS * $width, 100); 282389266c0SGreg Roach 28391bb35acSGreg Roach switch ($style) { 28491bb35acSGreg Roach case self::STYLE_HALF_CIRCLE: 28591bb35acSGreg Roach $chart_start_angle = 180; 28691bb35acSGreg Roach $chart_end_angle = 360; 28791bb35acSGreg Roach $height = intdiv($width, 2); 28891bb35acSGreg Roach break; 28991bb35acSGreg Roach 2900945b150SGreg Roach case self::STYLE_THREE_QUARTER_CIRCLE: 29191bb35acSGreg Roach $chart_start_angle = 135; 29291bb35acSGreg Roach $chart_end_angle = 405; 29391bb35acSGreg Roach $height = intdiv($width * 86, 100); 29491bb35acSGreg Roach break; 29591bb35acSGreg Roach 2960945b150SGreg Roach case self::STYLE_FULL_CIRCLE: 29791bb35acSGreg Roach default: 29891bb35acSGreg Roach $chart_start_angle = 90; 29991bb35acSGreg Roach $chart_end_angle = 450; 30091bb35acSGreg Roach $height = $width; 30191bb35acSGreg Roach break; 302389266c0SGreg Roach } 303389266c0SGreg Roach 30491bb35acSGreg Roach // Start with a transparent image. 30591bb35acSGreg Roach $image = imagecreate($width, $height); 306389266c0SGreg Roach $transparent = imagecolorallocate($image, 0, 0, 0); 307389266c0SGreg Roach imagecolortransparent($image, $transparent); 30891bb35acSGreg Roach imagefilledrectangle($image, 0, 0, $width, $height, $transparent); 309389266c0SGreg Roach 31091bb35acSGreg Roach // Use theme-specified colors. 311a91af26aSGreg Roach /** @var ModuleThemeInterface $theme */ 312cab242e7SGreg Roach $theme = app(ModuleThemeInterface::class); 31334b20f29SGreg Roach $text_color = $this->imageColor($image, '000000'); 314389266c0SGreg Roach $backgrounds = [ 31534b20f29SGreg Roach 'M' => $this->imageColor($image, 'b1cff0'), 31634b20f29SGreg Roach 'F' => $this->imageColor($image, 'e9daf1'), 31734b20f29SGreg Roach 'U' => $this->imageColor($image, 'eeeeee'), 318389266c0SGreg Roach ]; 319389266c0SGreg Roach 32091bb35acSGreg Roach // Co-ordinates are measured from the top-left corner. 32191bb35acSGreg Roach $center_x = intdiv($width, 2); 32291bb35acSGreg Roach $center_y = $center_x; 32391bb35acSGreg Roach $arc_width = $width / $generations / 2.0; 324389266c0SGreg Roach 32591bb35acSGreg Roach // Popup menus for each ancestor. 326389266c0SGreg Roach $html = ''; 327389266c0SGreg Roach 32891bb35acSGreg Roach // Areas for the image map. 329389266c0SGreg Roach $areas = ''; 330389266c0SGreg Roach 33191bb35acSGreg Roach for ($generation = $generations; $generation >= 1; $generation--) { 33291bb35acSGreg Roach // Which ancestors to include in this ring. 1, 2-3, 4-7, 8-15, 16-31, etc. 33391bb35acSGreg Roach // The end of the range is also the number of ancestors in the ring. 33491bb35acSGreg Roach $sosa_start = 2 ** $generation - 1; 33591bb35acSGreg Roach $sosa_end = 2 ** ($generation - 1); 336a18dc098SGreg Roach 33791bb35acSGreg Roach $arc_diameter = intdiv($width * $generation, $generations); 33891bb35acSGreg Roach $arc_radius = $arc_diameter / 2; 339389266c0SGreg Roach 34091bb35acSGreg Roach // Draw an empty background, for missing ancestors. 34191bb35acSGreg Roach imagefilledarc( 34291bb35acSGreg Roach $image, 34391bb35acSGreg Roach $center_x, 34491bb35acSGreg Roach $center_y, 34591bb35acSGreg Roach $arc_diameter, 34691bb35acSGreg Roach $arc_diameter, 34791bb35acSGreg Roach $chart_start_angle, 34891bb35acSGreg Roach $chart_end_angle, 34991bb35acSGreg Roach $backgrounds['U'], 35091bb35acSGreg Roach IMG_ARC_PIE 35191bb35acSGreg Roach ); 352389266c0SGreg Roach 35391bb35acSGreg Roach $arc_diameter -= 2 * self::GAP_BETWEEN_RINGS; 35491bb35acSGreg Roach 35591bb35acSGreg Roach for ($sosa = $sosa_start; $sosa >= $sosa_end; $sosa--) { 356389266c0SGreg Roach if ($ancestors->has($sosa)) { 35791bb35acSGreg Roach $individual = $ancestors->get($sosa); 358389266c0SGreg Roach 35991bb35acSGreg Roach $chart_angle = $chart_end_angle - $chart_start_angle; 36091bb35acSGreg Roach $start_angle = $chart_start_angle + intdiv($chart_angle * ($sosa - $sosa_end), $sosa_end); 36191bb35acSGreg Roach $end_angle = $chart_start_angle + intdiv($chart_angle * ($sosa - $sosa_end + 1), $sosa_end); 36291bb35acSGreg Roach $angle = $end_angle - $start_angle; 36391bb35acSGreg Roach 36491bb35acSGreg Roach imagefilledarc( 36591bb35acSGreg Roach $image, 36691bb35acSGreg Roach $center_x, 36791bb35acSGreg Roach $center_y, 36891bb35acSGreg Roach $arc_diameter, 36991bb35acSGreg Roach $arc_diameter, 37091bb35acSGreg Roach $start_angle, 37191bb35acSGreg Roach $end_angle, 37291bb35acSGreg Roach $backgrounds[$individual->sex()], 37391bb35acSGreg Roach IMG_ARC_PIE 37491bb35acSGreg Roach ); 37591bb35acSGreg Roach 37691bb35acSGreg Roach // Text is written at a tangent to the arc. 37791bb35acSGreg Roach $text_angle = 270.0 - ($start_angle + $end_angle) / 2.0; 37891bb35acSGreg Roach 37991bb35acSGreg Roach $text_radius = $arc_diameter / 2.0 - $arc_width * 0.25; 38091bb35acSGreg Roach 38191bb35acSGreg Roach // Don't draw text right up to the edge of the arc. 38291bb35acSGreg Roach if ($angle === 360) { 38391bb35acSGreg Roach $delta = 90; 38491bb35acSGreg Roach } elseif ($angle === 180) { 38591bb35acSGreg Roach if ($generation === 1) { 38691bb35acSGreg Roach $delta = 20; 38791bb35acSGreg Roach } else { 38891bb35acSGreg Roach $delta = 60; 38991bb35acSGreg Roach } 39091bb35acSGreg Roach } elseif ($angle > 120) { 39191bb35acSGreg Roach $delta = 45; 39291bb35acSGreg Roach } elseif ($angle > 60) { 39391bb35acSGreg Roach $delta = 15; 39491bb35acSGreg Roach } else { 39591bb35acSGreg Roach $delta = 1; 396389266c0SGreg Roach } 397389266c0SGreg Roach 39891bb35acSGreg Roach $tx_start = $center_x + $text_radius * cos(deg2rad($start_angle + $delta)); 39991bb35acSGreg Roach $ty_start = $center_y + $text_radius * sin(deg2rad($start_angle + $delta)); 40091bb35acSGreg Roach $tx_end = $center_x + $text_radius * cos(deg2rad($end_angle - $delta)); 40191bb35acSGreg Roach $ty_end = $center_y + $text_radius * sin(deg2rad($end_angle - $delta)); 402389266c0SGreg Roach 40391bb35acSGreg Roach $max_text_length = (int) sqrt(($tx_end - $tx_start) ** 2 + ($ty_end - $ty_start) ** 2); 404389266c0SGreg Roach 40591bb35acSGreg Roach $text_lines = array_filter([ 40691bb35acSGreg Roach I18N::reverseText($individual->fullName()), 40791bb35acSGreg Roach I18N::reverseText($individual->alternateName() ?? ''), 40891bb35acSGreg Roach I18N::reverseText($individual->lifespan()), 40991bb35acSGreg Roach ]); 410389266c0SGreg Roach 41191bb35acSGreg Roach $text_lines = array_map( 41291bb35acSGreg Roach fn (string $line): string => $this->fitTextToPixelWidth($line, $max_text_length), 41391bb35acSGreg Roach $text_lines 41491bb35acSGreg Roach ); 415389266c0SGreg Roach 41691bb35acSGreg Roach $text = implode("\n", $text_lines); 417389266c0SGreg Roach 41891bb35acSGreg Roach if ($generation === 1) { 41991bb35acSGreg Roach $ty_start -= $text_radius / 2; 420389266c0SGreg Roach } 421389266c0SGreg Roach 422acd78f72SGreg Roach // If PHP is compiled with --enable-gd-jis-conv, then the function 423acd78f72SGreg Roach // imagettftext() is modified to expect EUC-JP encoding instead of UTF-8. 424acd78f72SGreg Roach // Attempt to detect and convert... 425acd78f72SGreg Roach if (gd_info()['JIS-mapped Japanese Font Support'] ?? false) { 426acd78f72SGreg Roach $text = mb_convert_encoding($text, 'EUC-JP', 'UTF-8'); 427acd78f72SGreg Roach } 428acd78f72SGreg Roach 429389266c0SGreg Roach imagettftext( 430389266c0SGreg Roach $image, 43191bb35acSGreg Roach self::TEXT_SIZE_POINTS, 43291bb35acSGreg Roach $text_angle, 43391bb35acSGreg Roach (int) $tx_start, 43491bb35acSGreg Roach (int) $ty_start, 43591bb35acSGreg Roach $text_color, 43691bb35acSGreg Roach static::FONT, 437389266c0SGreg Roach $text 438389266c0SGreg Roach ); 43991bb35acSGreg Roach // Debug text positions by underlining first line of text 44091bb35acSGreg Roach //imageline($image, (int) $tx_start, (int) $ty_start, (int) $tx_end, (int) $ty_end, $backgrounds['U']); 441389266c0SGreg Roach 442389266c0SGreg Roach $areas .= '<area shape="poly" coords="'; 44391bb35acSGreg Roach for ($deg = $start_angle; $deg <= $end_angle; $deg++) { 444389266c0SGreg Roach $rad = deg2rad($deg); 44591bb35acSGreg Roach $areas .= round($center_x + $arc_radius * cos(deg2rad($rad)), 1) . ','; 44691bb35acSGreg Roach $areas .= round($center_y + $arc_radius * sin(deg2rad($rad)), 1) . ','; 447389266c0SGreg Roach } 44891bb35acSGreg Roach for ($deg = $end_angle; $deg >= $start_angle; $deg--) { 449389266c0SGreg Roach $rad = deg2rad($deg); 45091bb35acSGreg Roach $areas .= round($center_x + ($arc_radius - $arc_width) * cos($rad), 1) . ','; 45191bb35acSGreg Roach $areas .= round($center_y + ($arc_radius - $arc_width) * sin($rad), 1) . ','; 452389266c0SGreg Roach } 45391bb35acSGreg Roach $rad = deg2rad($start_angle); 45491bb35acSGreg Roach $areas .= round($center_x + $arc_radius * cos($rad), 1) . ','; 45591bb35acSGreg Roach $areas .= round($center_y + $arc_radius * sin($rad), 1) . '"'; 45691bb35acSGreg Roach 45791bb35acSGreg Roach $areas .= ' href="#' . e($individual->xref()) . '"'; 45891bb35acSGreg Roach $areas .= ' alt="' . strip_tags($individual->fullName()) . '"'; 45991bb35acSGreg Roach $areas .= ' title="' . strip_tags($individual->fullName()) . '">'; 46091bb35acSGreg Roach 46191bb35acSGreg Roach $html .= '<div id="' . $individual->xref() . '" class="fan_chart_menu">'; 46291bb35acSGreg Roach $html .= '<a href="' . e($individual->url()) . '" class="dropdown-item p-1">'; 46391bb35acSGreg Roach $html .= $individual->fullName(); 46491bb35acSGreg Roach $html .= '</a>'; 46591bb35acSGreg Roach 46691bb35acSGreg Roach foreach ($theme->individualBoxMenu($individual) as $menu) { 46791bb35acSGreg Roach $link = $menu->getLink(); 46891bb35acSGreg Roach $class = $menu->getClass(); 46991bb35acSGreg Roach $html .= '<a href="' . e($link) . '" class="dropdown-item p-1 ' . e($class) . '">'; 47091bb35acSGreg Roach $html .= $menu->getLabel(); 47191bb35acSGreg Roach $html .= '</a>'; 472389266c0SGreg Roach } 47391bb35acSGreg Roach 474b6c326d8SGreg Roach $html .= '</div>'; 475389266c0SGreg Roach } 476389266c0SGreg Roach } 477389266c0SGreg Roach } 478389266c0SGreg Roach 479389266c0SGreg Roach ob_start(); 480389266c0SGreg Roach imagepng($image); 481389266c0SGreg Roach imagedestroy($image); 482389266c0SGreg Roach $png = ob_get_clean(); 483389266c0SGreg Roach 4846ccdf4f0SGreg Roach return response(view('modules/fanchart/chart', [ 48591bb35acSGreg Roach 'fanh' => $height, 48691bb35acSGreg Roach 'fanw' => $width, 487389266c0SGreg Roach 'html' => $html, 488389266c0SGreg Roach 'areas' => $areas, 489389266c0SGreg Roach 'png' => $png, 490389266c0SGreg Roach 'title' => $this->chartTitle($individual), 491389266c0SGreg Roach ])); 492389266c0SGreg Roach } 493389266c0SGreg Roach 494389266c0SGreg Roach /** 495389266c0SGreg Roach * Convert a CSS color into a GD color. 496389266c0SGreg Roach * 497389266c0SGreg Roach * @param resource $image 498389266c0SGreg Roach * @param string $css_color 499389266c0SGreg Roach * 500389266c0SGreg Roach * @return int 501389266c0SGreg Roach */ 502389266c0SGreg Roach protected function imageColor($image, string $css_color): int 503389266c0SGreg Roach { 504389266c0SGreg Roach return imagecolorallocate( 505389266c0SGreg Roach $image, 506389266c0SGreg Roach (int) hexdec(substr($css_color, 0, 2)), 507389266c0SGreg Roach (int) hexdec(substr($css_color, 2, 2)), 508389266c0SGreg Roach (int) hexdec(substr($css_color, 4, 2)) 509389266c0SGreg Roach ); 510389266c0SGreg Roach } 511389266c0SGreg Roach 512389266c0SGreg Roach /** 513389266c0SGreg Roach * This chart can display its output in a number of styles 514389266c0SGreg Roach * 515fc26b4f6SGreg Roach * @return array<string> 516389266c0SGreg Roach */ 51771378461SGreg Roach protected function styles(): array 518389266c0SGreg Roach { 519389266c0SGreg Roach return [ 520389266c0SGreg Roach /* I18N: layout option for the fan chart */ 521389266c0SGreg Roach self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), 522389266c0SGreg Roach /* I18N: layout option for the fan chart */ 523389266c0SGreg Roach self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), 524389266c0SGreg Roach /* I18N: layout option for the fan chart */ 525389266c0SGreg Roach self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), 526389266c0SGreg Roach ]; 527e6562982SGreg Roach } 52891bb35acSGreg Roach 52991bb35acSGreg Roach /** 53091bb35acSGreg Roach * Fit text to a given number of pixels by either cropping to fit, 53191bb35acSGreg Roach * or adding spaces to center. 53291bb35acSGreg Roach * 53391bb35acSGreg Roach * @param string $text 53491bb35acSGreg Roach * @param int $pixels 53591bb35acSGreg Roach * 53691bb35acSGreg Roach * @return string 53791bb35acSGreg Roach */ 53891bb35acSGreg Roach protected function fitTextToPixelWidth(string $text, int $pixels): string 53991bb35acSGreg Roach { 54091bb35acSGreg Roach while ($this->textWidthInPixels($text) > $pixels) { 54191bb35acSGreg Roach $text = mb_substr($text, 0, -1); 54291bb35acSGreg Roach } 54391bb35acSGreg Roach 54491bb35acSGreg Roach while ($this->textWidthInPixels(' ' . $text . ' ') < $pixels) { 54591bb35acSGreg Roach $text = ' ' . $text . ' '; 54691bb35acSGreg Roach } 54791bb35acSGreg Roach 54891bb35acSGreg Roach // We only need the leading spaces. 54991bb35acSGreg Roach return rtrim($text); 55091bb35acSGreg Roach } 55191bb35acSGreg Roach 55291bb35acSGreg Roach /** 55391bb35acSGreg Roach * @param string $text 55491bb35acSGreg Roach * 55591bb35acSGreg Roach * @return int 55691bb35acSGreg Roach */ 55791bb35acSGreg Roach protected function textWidthInPixels(string $text): int 55891bb35acSGreg Roach { 55991bb35acSGreg Roach $bounding_box = imagettfbbox(self::TEXT_SIZE_POINTS, 0, self::FONT, $text); 56091bb35acSGreg Roach 56191bb35acSGreg Roach return $bounding_box[4] - $bounding_box[0]; 56291bb35acSGreg Roach } 563168ff6f3Sric2016} 564