1168ff6f3Sric2016<?php 23976b470SGreg Roach 3168ff6f3Sric2016/** 4168ff6f3Sric2016 * webtrees: online genealogy 5d11be702SGreg Roach * Copyright (C) 2023 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 Fig\Http\Message\RequestMethodInterface; 23389266c0SGreg Roachuse Fisharebest\Webtrees\Auth; 24168ff6f3Sric2016use Fisharebest\Webtrees\I18N; 25168ff6f3Sric2016use Fisharebest\Webtrees\Individual; 266664b4a3SGreg Roachuse Fisharebest\Webtrees\Menu; 2791bb35acSGreg Roachuse Fisharebest\Webtrees\Registry; 28389266c0SGreg Roachuse Fisharebest\Webtrees\Services\ChartService; 29b55cbc6bSGreg Roachuse Fisharebest\Webtrees\Validator; 30f397d0fdSGreg Roachuse Fisharebest\Webtrees\Webtrees; 31f117d295SGreg Roachuse GdImage; 326ccdf4f0SGreg Roachuse Psr\Http\Message\ResponseInterface; 336ccdf4f0SGreg Roachuse Psr\Http\Message\ServerRequestInterface; 3471378461SGreg Roachuse Psr\Http\Server\RequestHandlerInterface; 3571378461SGreg Roach 3691bb35acSGreg Roachuse function array_filter; 3791bb35acSGreg Roachuse function array_map; 3891bb35acSGreg Roachuse function cos; 3991bb35acSGreg Roachuse function deg2rad; 4091bb35acSGreg Roachuse function e; 4191bb35acSGreg Roachuse function gd_info; 4291bb35acSGreg Roachuse function hexdec; 4391bb35acSGreg Roachuse function imagecolorallocate; 4491bb35acSGreg Roachuse function imagecolortransparent; 4591bb35acSGreg Roachuse function imagecreate; 4691bb35acSGreg Roachuse function imagefilledarc; 4791bb35acSGreg Roachuse function imagefilledrectangle; 4891bb35acSGreg Roachuse function imagepng; 4991bb35acSGreg Roachuse function imagettfbbox; 5091bb35acSGreg Roachuse function imagettftext; 5171378461SGreg Roachuse function implode; 52a18dc098SGreg Roachuse function intdiv; 5391bb35acSGreg Roachuse function mb_substr; 5491bb35acSGreg Roachuse function ob_get_clean; 5591bb35acSGreg Roachuse function ob_start; 5671378461SGreg Roachuse function redirect; 5791bb35acSGreg Roachuse function response; 5891bb35acSGreg Roachuse function round; 5971378461SGreg Roachuse function route; 6091bb35acSGreg Roachuse function rtrim; 6191bb35acSGreg Roachuse function sin; 6291bb35acSGreg Roachuse function sqrt; 6391bb35acSGreg Roachuse function strip_tags; 6491bb35acSGreg Roachuse function substr; 6591bb35acSGreg Roachuse function view; 6691bb35acSGreg Roach 6791bb35acSGreg Roachuse const IMG_ARC_PIE; 68168ff6f3Sric2016 69168ff6f3Sric2016/** 70168ff6f3Sric2016 * Class FanChartModule 71168ff6f3Sric2016 */ 7271378461SGreg Roachclass FanChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface 73c1010edaSGreg Roach{ 7449a243cbSGreg Roach use ModuleChartTrait; 7549a243cbSGreg Roach 7672f04adfSGreg Roach protected const ROUTE_URL = '/tree/{tree}/fan-chart-{style}-{generations}-{width}/{xref}'; 7771378461SGreg Roach 78389266c0SGreg Roach // Chart styles 79d2d056daSGreg Roach private const STYLE_HALF_CIRCLE = 2; 80d2d056daSGreg Roach private const STYLE_THREE_QUARTER_CIRCLE = 3; 81d2d056daSGreg Roach private const STYLE_FULL_CIRCLE = 4; 8271378461SGreg Roach 8371378461SGreg Roach // Defaults 84266e9c61SGreg Roach public const DEFAULT_STYLE = self::STYLE_THREE_QUARTER_CIRCLE; 85266e9c61SGreg Roach public const DEFAULT_GENERATIONS = 4; 86266e9c61SGreg Roach public const DEFAULT_WIDTH = 100; 8771378461SGreg Roach protected const DEFAULT_PARAMETERS = [ 8871378461SGreg Roach 'style' => self::DEFAULT_STYLE, 8971378461SGreg Roach 'generations' => self::DEFAULT_GENERATIONS, 9071378461SGreg Roach 'width' => self::DEFAULT_WIDTH, 9171378461SGreg Roach ]; 92389266c0SGreg Roach 93389266c0SGreg Roach // Limits 94389266c0SGreg Roach private const MINIMUM_GENERATIONS = 2; 95389266c0SGreg Roach private const MAXIMUM_GENERATIONS = 9; 96389266c0SGreg Roach private const MINIMUM_WIDTH = 50; 97389266c0SGreg Roach private const MAXIMUM_WIDTH = 500; 98389266c0SGreg Roach 9991bb35acSGreg Roach // Chart layout parameters 10091bb35acSGreg Roach private const FONT = Webtrees::ROOT_DIR . 'resources/fonts/DejaVuSans.ttf'; 10191bb35acSGreg Roach private const CHART_WIDTH_PIXELS = 800; 10291bb35acSGreg Roach private const TEXT_SIZE_POINTS = self::CHART_WIDTH_PIXELS / 120.0; 10391bb35acSGreg Roach private const GAP_BETWEEN_RINGS = 2; 10491bb35acSGreg Roach 10591bb35acSGreg Roach private ChartService $chart_service; 10657ab2231SGreg Roach 10757ab2231SGreg Roach /** 10857ab2231SGreg Roach * FanChartModule constructor. 10957ab2231SGreg Roach * 11057ab2231SGreg Roach * @param ChartService $chart_service 11157ab2231SGreg Roach */ 1123976b470SGreg Roach public function __construct(ChartService $chart_service) 1133976b470SGreg Roach { 11457ab2231SGreg Roach $this->chart_service = $chart_service; 11557ab2231SGreg Roach } 11657ab2231SGreg Roach 117168ff6f3Sric2016 /** 11871378461SGreg Roach * Initialization. 11971378461SGreg Roach * 1209e18e23bSGreg Roach * @return void 12171378461SGreg Roach */ 1229e18e23bSGreg Roach public function boot(): void 12371378461SGreg Roach { 124158900c2SGreg Roach Registry::routeFactory()->routeMap() 12572f04adfSGreg Roach ->get(static::class, static::ROUTE_URL, $this) 126158900c2SGreg Roach ->allows(RequestMethodInterface::METHOD_POST); 12771378461SGreg Roach } 12871378461SGreg Roach 12971378461SGreg Roach /** 1300cfd6963SGreg Roach * How should this module be identified in the control panel, etc.? 131168ff6f3Sric2016 * 132168ff6f3Sric2016 * @return string 133168ff6f3Sric2016 */ 13449a243cbSGreg Roach public function title(): string 135c1010edaSGreg Roach { 136bbb76c12SGreg Roach /* I18N: Name of a module/chart */ 137bbb76c12SGreg Roach return I18N::translate('Fan chart'); 138168ff6f3Sric2016 } 139168ff6f3Sric2016 140168ff6f3Sric2016 /** 141168ff6f3Sric2016 * A sentence describing what this module does. 142168ff6f3Sric2016 * 143168ff6f3Sric2016 * @return string 144168ff6f3Sric2016 */ 14549a243cbSGreg Roach public function description(): string 146c1010edaSGreg Roach { 147bbb76c12SGreg Roach /* I18N: Description of the “Fan Chart” module */ 148bbb76c12SGreg Roach return I18N::translate('A fan chart of an individual’s ancestors.'); 149168ff6f3Sric2016 } 150168ff6f3Sric2016 151168ff6f3Sric2016 /** 152377a2979SGreg Roach * CSS class for the URL. 153377a2979SGreg Roach * 154377a2979SGreg Roach * @return string 155377a2979SGreg Roach */ 156377a2979SGreg Roach public function chartMenuClass(): string 157377a2979SGreg Roach { 158377a2979SGreg Roach return 'menu-chart-fanchart'; 159377a2979SGreg Roach } 160377a2979SGreg Roach 161377a2979SGreg Roach /** 1624eb71cfaSGreg Roach * Return a menu item for this chart - for use in individual boxes. 1634eb71cfaSGreg Roach * 16460bc3e3fSGreg Roach * @param Individual $individual 16560bc3e3fSGreg Roach * 1664eb71cfaSGreg Roach * @return Menu|null 1674eb71cfaSGreg Roach */ 168377a2979SGreg Roach public function chartBoxMenu(Individual $individual): ?Menu 169c1010edaSGreg Roach { 170e6562982SGreg Roach return $this->chartMenu($individual); 171e6562982SGreg Roach } 172e6562982SGreg Roach 173e6562982SGreg Roach /** 174e6562982SGreg Roach * The title for a specific instance of this chart. 175e6562982SGreg Roach * 176e6562982SGreg Roach * @param Individual $individual 177e6562982SGreg Roach * 178e6562982SGreg Roach * @return string 179e6562982SGreg Roach */ 180e6562982SGreg Roach public function chartTitle(Individual $individual): string 181e6562982SGreg Roach { 182ad3143ccSGreg Roach /* I18N: https://en.wikipedia.org/wiki/Family_tree#Fan_chart - %s is an individual’s name */ 18339ca88baSGreg Roach return I18N::translate('Fan chart of %s', $individual->fullName()); 184e6562982SGreg Roach } 185e6562982SGreg Roach 186e6562982SGreg Roach /** 187389266c0SGreg Roach * A form to request the chart parameters. 188389266c0SGreg Roach * 18971378461SGreg Roach * @param Individual $individual 19076d39c55SGreg Roach * @param array<bool|int|string|array<string>|null> $parameters 19171378461SGreg Roach * 19271378461SGreg Roach * @return string 19371378461SGreg Roach */ 19471378461SGreg Roach public function chartUrl(Individual $individual, array $parameters = []): string 19571378461SGreg Roach { 19672f04adfSGreg Roach return route(static::class, [ 19771378461SGreg Roach 'xref' => $individual->xref(), 19871378461SGreg Roach 'tree' => $individual->tree()->name(), 19971378461SGreg Roach ] + $parameters + self::DEFAULT_PARAMETERS); 20071378461SGreg Roach } 20171378461SGreg Roach 20271378461SGreg Roach /** 2036ccdf4f0SGreg Roach * @param ServerRequestInterface $request 204389266c0SGreg Roach * 2056ccdf4f0SGreg Roach * @return ResponseInterface 206389266c0SGreg Roach */ 20771378461SGreg Roach public function handle(ServerRequestInterface $request): ResponseInterface 208389266c0SGreg Roach { 209b55cbc6bSGreg Roach $tree = Validator::attributes($request)->tree(); 210b55cbc6bSGreg Roach $user = Validator::attributes($request)->user(); 211b55cbc6bSGreg Roach $xref = Validator::attributes($request)->isXref()->string('xref'); 212d2d056daSGreg Roach $style = Validator::attributes($request)->isInArrayKeys($this->styles())->integer('style'); 213b55cbc6bSGreg Roach $generations = Validator::attributes($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'); 214b55cbc6bSGreg Roach $width = Validator::attributes($request)->isBetween(self::MINIMUM_WIDTH, self::MAXIMUM_WIDTH)->integer('width'); 215b55cbc6bSGreg Roach $ajax = Validator::queryParams($request)->boolean('ajax', false); 216389266c0SGreg Roach 21771378461SGreg Roach // Convert POST requests into GET requests for pretty URLs. 21871378461SGreg Roach if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 21972f04adfSGreg Roach return redirect(route(static::class, [ 2204ea62551SGreg Roach 'tree' => $tree->name(), 221158900c2SGreg Roach 'generations' => Validator::parsedBody($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'), 222d2d056daSGreg Roach 'style' => Validator::parsedBody($request)->isInArrayKeys($this->styles())->integer('style'), 223158900c2SGreg Roach 'width' => Validator::parsedBody($request)->isBetween(self::MINIMUM_WIDTH, self::MAXIMUM_WIDTH)->integer('width'), 224158900c2SGreg Roach 'xref' => Validator::parsedBody($request)->isXref()->string('xref'), 22571378461SGreg Roach ])); 22671378461SGreg Roach } 22771378461SGreg Roach 228ef483801SGreg Roach Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); 229389266c0SGreg Roach 230b55cbc6bSGreg Roach $individual = Registry::individualFactory()->make($xref, $tree); 231b55cbc6bSGreg Roach $individual = Auth::checkIndividualAccess($individual, false, true); 232389266c0SGreg Roach 233b55cbc6bSGreg Roach if ($ajax) { 23471378461SGreg Roach return $this->chart($individual, $style, $width, $generations); 235389266c0SGreg Roach } 236389266c0SGreg Roach 237389266c0SGreg Roach $ajax_url = $this->chartUrl($individual, [ 2389b5537c3SGreg Roach 'ajax' => true, 239389266c0SGreg Roach 'generations' => $generations, 24071378461SGreg Roach 'style' => $style, 24171378461SGreg Roach 'width' => $width, 242389266c0SGreg Roach ]); 243389266c0SGreg Roach 2449b5537c3SGreg Roach return $this->viewResponse('modules/fanchart/page', [ 245389266c0SGreg Roach 'ajax_url' => $ajax_url, 246389266c0SGreg Roach 'generations' => $generations, 247389266c0SGreg Roach 'individual' => $individual, 248389266c0SGreg Roach 'maximum_generations' => self::MAXIMUM_GENERATIONS, 249389266c0SGreg Roach 'minimum_generations' => self::MINIMUM_GENERATIONS, 250389266c0SGreg Roach 'maximum_width' => self::MAXIMUM_WIDTH, 251389266c0SGreg Roach 'minimum_width' => self::MINIMUM_WIDTH, 25271378461SGreg Roach 'module' => $this->name(), 25371378461SGreg Roach 'style' => $style, 25471378461SGreg Roach 'styles' => $this->styles(), 255389266c0SGreg Roach 'title' => $this->chartTitle($individual), 256ef5d23f1SGreg Roach 'tree' => $tree, 25771378461SGreg Roach 'width' => $width, 258389266c0SGreg Roach ]); 259389266c0SGreg Roach } 260389266c0SGreg Roach 261389266c0SGreg Roach /** 262389266c0SGreg Roach * Generate both the HTML and PNG components of the fan chart 263e6562982SGreg Roach * 264e6562982SGreg Roach * @param Individual $individual 265d2d056daSGreg Roach * @param int $style 26671378461SGreg Roach * @param int $width 267389266c0SGreg Roach * @param int $generations 268e6562982SGreg Roach * 2696ccdf4f0SGreg Roach * @return ResponseInterface 270e6562982SGreg Roach */ 271d2d056daSGreg Roach protected function chart(Individual $individual, int $style, int $width, int $generations): ResponseInterface 272e6562982SGreg Roach { 27371378461SGreg Roach $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations); 274389266c0SGreg Roach 27591bb35acSGreg Roach $width = intdiv(self::CHART_WIDTH_PIXELS * $width, 100); 276389266c0SGreg Roach 27791bb35acSGreg Roach switch ($style) { 27891bb35acSGreg Roach case self::STYLE_HALF_CIRCLE: 27991bb35acSGreg Roach $chart_start_angle = 180; 28091bb35acSGreg Roach $chart_end_angle = 360; 28191bb35acSGreg Roach $height = intdiv($width, 2); 28291bb35acSGreg Roach break; 28391bb35acSGreg Roach 2840945b150SGreg Roach case self::STYLE_THREE_QUARTER_CIRCLE: 28591bb35acSGreg Roach $chart_start_angle = 135; 28691bb35acSGreg Roach $chart_end_angle = 405; 28791bb35acSGreg Roach $height = intdiv($width * 86, 100); 28891bb35acSGreg Roach break; 28991bb35acSGreg Roach 2900945b150SGreg Roach case self::STYLE_FULL_CIRCLE: 29191bb35acSGreg Roach default: 29291bb35acSGreg Roach $chart_start_angle = 90; 29391bb35acSGreg Roach $chart_end_angle = 450; 29491bb35acSGreg Roach $height = $width; 29591bb35acSGreg Roach break; 296389266c0SGreg Roach } 297389266c0SGreg Roach 29891bb35acSGreg Roach // Start with a transparent image. 29991bb35acSGreg Roach $image = imagecreate($width, $height); 300389266c0SGreg Roach $transparent = imagecolorallocate($image, 0, 0, 0); 301389266c0SGreg Roach imagecolortransparent($image, $transparent); 30291bb35acSGreg Roach imagefilledrectangle($image, 0, 0, $width, $height, $transparent); 303389266c0SGreg Roach 30491bb35acSGreg Roach // Use theme-specified colors. 305*d35568b4SGreg Roach $theme = Registry::container()->get(ModuleThemeInterface::class); 30634b20f29SGreg Roach $text_color = $this->imageColor($image, '000000'); 307389266c0SGreg Roach $backgrounds = [ 30834b20f29SGreg Roach 'M' => $this->imageColor($image, 'b1cff0'), 30934b20f29SGreg Roach 'F' => $this->imageColor($image, 'e9daf1'), 31034b20f29SGreg Roach 'U' => $this->imageColor($image, 'eeeeee'), 311389266c0SGreg Roach ]; 312389266c0SGreg Roach 31391bb35acSGreg Roach // Co-ordinates are measured from the top-left corner. 31491bb35acSGreg Roach $center_x = intdiv($width, 2); 31591bb35acSGreg Roach $center_y = $center_x; 31691bb35acSGreg Roach $arc_width = $width / $generations / 2.0; 317389266c0SGreg Roach 31891bb35acSGreg Roach // Popup menus for each ancestor. 319389266c0SGreg Roach $html = ''; 320389266c0SGreg Roach 32191bb35acSGreg Roach // Areas for the image map. 322389266c0SGreg Roach $areas = ''; 323389266c0SGreg Roach 32491bb35acSGreg Roach for ($generation = $generations; $generation >= 1; $generation--) { 32591bb35acSGreg Roach // Which ancestors to include in this ring. 1, 2-3, 4-7, 8-15, 16-31, etc. 32691bb35acSGreg Roach // The end of the range is also the number of ancestors in the ring. 32791bb35acSGreg Roach $sosa_start = 2 ** $generation - 1; 32891bb35acSGreg Roach $sosa_end = 2 ** ($generation - 1); 329a18dc098SGreg Roach 33091bb35acSGreg Roach $arc_diameter = intdiv($width * $generation, $generations); 33191bb35acSGreg Roach $arc_radius = $arc_diameter / 2; 332389266c0SGreg Roach 33391bb35acSGreg Roach // Draw an empty background, for missing ancestors. 33491bb35acSGreg Roach imagefilledarc( 33591bb35acSGreg Roach $image, 33691bb35acSGreg Roach $center_x, 33791bb35acSGreg Roach $center_y, 33891bb35acSGreg Roach $arc_diameter, 33991bb35acSGreg Roach $arc_diameter, 34091bb35acSGreg Roach $chart_start_angle, 34191bb35acSGreg Roach $chart_end_angle, 34291bb35acSGreg Roach $backgrounds['U'], 34391bb35acSGreg Roach IMG_ARC_PIE 34491bb35acSGreg Roach ); 345389266c0SGreg Roach 34691bb35acSGreg Roach $arc_diameter -= 2 * self::GAP_BETWEEN_RINGS; 34791bb35acSGreg Roach 34891bb35acSGreg Roach for ($sosa = $sosa_start; $sosa >= $sosa_end; $sosa--) { 349389266c0SGreg Roach if ($ancestors->has($sosa)) { 35091bb35acSGreg Roach $individual = $ancestors->get($sosa); 351389266c0SGreg Roach 35291bb35acSGreg Roach $chart_angle = $chart_end_angle - $chart_start_angle; 35391bb35acSGreg Roach $start_angle = $chart_start_angle + intdiv($chart_angle * ($sosa - $sosa_end), $sosa_end); 35491bb35acSGreg Roach $end_angle = $chart_start_angle + intdiv($chart_angle * ($sosa - $sosa_end + 1), $sosa_end); 35591bb35acSGreg Roach $angle = $end_angle - $start_angle; 35691bb35acSGreg Roach 35791bb35acSGreg Roach imagefilledarc( 35891bb35acSGreg Roach $image, 35991bb35acSGreg Roach $center_x, 36091bb35acSGreg Roach $center_y, 36191bb35acSGreg Roach $arc_diameter, 36291bb35acSGreg Roach $arc_diameter, 36391bb35acSGreg Roach $start_angle, 36491bb35acSGreg Roach $end_angle, 36523a98013SGreg Roach $backgrounds[$individual->sex()] ?? $backgrounds['U'], 36691bb35acSGreg Roach IMG_ARC_PIE 36791bb35acSGreg Roach ); 36891bb35acSGreg Roach 36991bb35acSGreg Roach // Text is written at a tangent to the arc. 37091bb35acSGreg Roach $text_angle = 270.0 - ($start_angle + $end_angle) / 2.0; 37191bb35acSGreg Roach 37291bb35acSGreg Roach $text_radius = $arc_diameter / 2.0 - $arc_width * 0.25; 37391bb35acSGreg Roach 37491bb35acSGreg Roach // Don't draw text right up to the edge of the arc. 37591bb35acSGreg Roach if ($angle === 360) { 37691bb35acSGreg Roach $delta = 90; 37791bb35acSGreg Roach } elseif ($angle === 180) { 37891bb35acSGreg Roach if ($generation === 1) { 37991bb35acSGreg Roach $delta = 20; 38091bb35acSGreg Roach } else { 38191bb35acSGreg Roach $delta = 60; 38291bb35acSGreg Roach } 38391bb35acSGreg Roach } elseif ($angle > 120) { 38491bb35acSGreg Roach $delta = 45; 38591bb35acSGreg Roach } elseif ($angle > 60) { 38691bb35acSGreg Roach $delta = 15; 38791bb35acSGreg Roach } else { 38891bb35acSGreg Roach $delta = 1; 389389266c0SGreg Roach } 390389266c0SGreg Roach 39191bb35acSGreg Roach $tx_start = $center_x + $text_radius * cos(deg2rad($start_angle + $delta)); 39291bb35acSGreg Roach $ty_start = $center_y + $text_radius * sin(deg2rad($start_angle + $delta)); 39391bb35acSGreg Roach $tx_end = $center_x + $text_radius * cos(deg2rad($end_angle - $delta)); 39491bb35acSGreg Roach $ty_end = $center_y + $text_radius * sin(deg2rad($end_angle - $delta)); 395389266c0SGreg Roach 39691bb35acSGreg Roach $max_text_length = (int) sqrt(($tx_end - $tx_start) ** 2 + ($ty_end - $ty_start) ** 2); 397389266c0SGreg Roach 39891bb35acSGreg Roach $text_lines = array_filter([ 39991bb35acSGreg Roach I18N::reverseText($individual->fullName()), 40091bb35acSGreg Roach I18N::reverseText($individual->alternateName() ?? ''), 40191bb35acSGreg Roach I18N::reverseText($individual->lifespan()), 40291bb35acSGreg Roach ]); 403389266c0SGreg Roach 40491bb35acSGreg Roach $text_lines = array_map( 40591bb35acSGreg Roach fn (string $line): string => $this->fitTextToPixelWidth($line, $max_text_length), 40691bb35acSGreg Roach $text_lines 40791bb35acSGreg Roach ); 408389266c0SGreg Roach 40991bb35acSGreg Roach $text = implode("\n", $text_lines); 410389266c0SGreg Roach 41191bb35acSGreg Roach if ($generation === 1) { 41291bb35acSGreg Roach $ty_start -= $text_radius / 2; 413389266c0SGreg Roach } 414389266c0SGreg Roach 415acd78f72SGreg Roach // If PHP is compiled with --enable-gd-jis-conv, then the function 416acd78f72SGreg Roach // imagettftext() is modified to expect EUC-JP encoding instead of UTF-8. 417acd78f72SGreg Roach // Attempt to detect and convert... 418acd78f72SGreg Roach if (gd_info()['JIS-mapped Japanese Font Support'] ?? false) { 419acd78f72SGreg Roach $text = mb_convert_encoding($text, 'EUC-JP', 'UTF-8'); 420acd78f72SGreg Roach } 421acd78f72SGreg Roach 422389266c0SGreg Roach imagettftext( 423389266c0SGreg Roach $image, 42491bb35acSGreg Roach self::TEXT_SIZE_POINTS, 42591bb35acSGreg Roach $text_angle, 42691bb35acSGreg Roach (int) $tx_start, 42791bb35acSGreg Roach (int) $ty_start, 42891bb35acSGreg Roach $text_color, 42991bb35acSGreg Roach static::FONT, 430389266c0SGreg Roach $text 431389266c0SGreg Roach ); 43291bb35acSGreg Roach // Debug text positions by underlining first line of text 43391bb35acSGreg Roach //imageline($image, (int) $tx_start, (int) $ty_start, (int) $tx_end, (int) $ty_end, $backgrounds['U']); 434389266c0SGreg Roach 435389266c0SGreg Roach $areas .= '<area shape="poly" coords="'; 43691bb35acSGreg Roach for ($deg = $start_angle; $deg <= $end_angle; $deg++) { 437389266c0SGreg Roach $rad = deg2rad($deg); 43891bb35acSGreg Roach $areas .= round($center_x + $arc_radius * cos(deg2rad($rad)), 1) . ','; 43991bb35acSGreg Roach $areas .= round($center_y + $arc_radius * sin(deg2rad($rad)), 1) . ','; 440389266c0SGreg Roach } 44191bb35acSGreg Roach for ($deg = $end_angle; $deg >= $start_angle; $deg--) { 442389266c0SGreg Roach $rad = deg2rad($deg); 44391bb35acSGreg Roach $areas .= round($center_x + ($arc_radius - $arc_width) * cos($rad), 1) . ','; 44491bb35acSGreg Roach $areas .= round($center_y + ($arc_radius - $arc_width) * sin($rad), 1) . ','; 445389266c0SGreg Roach } 44691bb35acSGreg Roach $rad = deg2rad($start_angle); 44791bb35acSGreg Roach $areas .= round($center_x + $arc_radius * cos($rad), 1) . ','; 44891bb35acSGreg Roach $areas .= round($center_y + $arc_radius * sin($rad), 1) . '"'; 44991bb35acSGreg Roach 45091bb35acSGreg Roach $areas .= ' href="#' . e($individual->xref()) . '"'; 45191bb35acSGreg Roach $areas .= ' alt="' . strip_tags($individual->fullName()) . '"'; 45291bb35acSGreg Roach $areas .= ' title="' . strip_tags($individual->fullName()) . '">'; 45391bb35acSGreg Roach 45491bb35acSGreg Roach $html .= '<div id="' . $individual->xref() . '" class="fan_chart_menu">'; 45591bb35acSGreg Roach $html .= '<a href="' . e($individual->url()) . '" class="dropdown-item p-1">'; 45691bb35acSGreg Roach $html .= $individual->fullName(); 45791bb35acSGreg Roach $html .= '</a>'; 45891bb35acSGreg Roach 45991bb35acSGreg Roach foreach ($theme->individualBoxMenu($individual) as $menu) { 46091bb35acSGreg Roach $link = $menu->getLink(); 46191bb35acSGreg Roach $class = $menu->getClass(); 46291bb35acSGreg Roach $html .= '<a href="' . e($link) . '" class="dropdown-item p-1 ' . e($class) . '">'; 46391bb35acSGreg Roach $html .= $menu->getLabel(); 46491bb35acSGreg Roach $html .= '</a>'; 465389266c0SGreg Roach } 46691bb35acSGreg Roach 467b6c326d8SGreg Roach $html .= '</div>'; 468389266c0SGreg Roach } 469389266c0SGreg Roach } 470389266c0SGreg Roach } 471389266c0SGreg Roach 472389266c0SGreg Roach ob_start(); 473389266c0SGreg Roach imagepng($image); 474389266c0SGreg Roach $png = ob_get_clean(); 475389266c0SGreg Roach 4766ccdf4f0SGreg Roach return response(view('modules/fanchart/chart', [ 47791bb35acSGreg Roach 'fanh' => $height, 47891bb35acSGreg Roach 'fanw' => $width, 479389266c0SGreg Roach 'html' => $html, 480389266c0SGreg Roach 'areas' => $areas, 481389266c0SGreg Roach 'png' => $png, 482389266c0SGreg Roach 'title' => $this->chartTitle($individual), 483389266c0SGreg Roach ])); 484389266c0SGreg Roach } 485389266c0SGreg Roach 486389266c0SGreg Roach /** 487389266c0SGreg Roach * Convert a CSS color into a GD color. 488389266c0SGreg Roach * 489f117d295SGreg Roach * @param GdImage $image 490389266c0SGreg Roach * @param string $css_color 491389266c0SGreg Roach * 492389266c0SGreg Roach * @return int 493389266c0SGreg Roach */ 494f117d295SGreg Roach protected function imageColor(GdImage $image, string $css_color): int 495389266c0SGreg Roach { 496389266c0SGreg Roach return imagecolorallocate( 497389266c0SGreg Roach $image, 498389266c0SGreg Roach (int) hexdec(substr($css_color, 0, 2)), 499389266c0SGreg Roach (int) hexdec(substr($css_color, 2, 2)), 500389266c0SGreg Roach (int) hexdec(substr($css_color, 4, 2)) 501389266c0SGreg Roach ); 502389266c0SGreg Roach } 503389266c0SGreg Roach 504389266c0SGreg Roach /** 505389266c0SGreg Roach * This chart can display its output in a number of styles 506389266c0SGreg Roach * 507fc26b4f6SGreg Roach * @return array<string> 508389266c0SGreg Roach */ 50971378461SGreg Roach protected function styles(): array 510389266c0SGreg Roach { 511389266c0SGreg Roach return [ 512389266c0SGreg Roach /* I18N: layout option for the fan chart */ 513389266c0SGreg Roach self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), 514389266c0SGreg Roach /* I18N: layout option for the fan chart */ 515389266c0SGreg Roach self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), 516389266c0SGreg Roach /* I18N: layout option for the fan chart */ 517389266c0SGreg Roach self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), 518389266c0SGreg Roach ]; 519e6562982SGreg Roach } 52091bb35acSGreg Roach 52191bb35acSGreg Roach /** 52291bb35acSGreg Roach * Fit text to a given number of pixels by either cropping to fit, 52391bb35acSGreg Roach * or adding spaces to center. 52491bb35acSGreg Roach * 52591bb35acSGreg Roach * @param string $text 52691bb35acSGreg Roach * @param int $pixels 52791bb35acSGreg Roach * 52891bb35acSGreg Roach * @return string 52991bb35acSGreg Roach */ 53091bb35acSGreg Roach protected function fitTextToPixelWidth(string $text, int $pixels): string 53191bb35acSGreg Roach { 53291bb35acSGreg Roach while ($this->textWidthInPixels($text) > $pixels) { 53391bb35acSGreg Roach $text = mb_substr($text, 0, -1); 53491bb35acSGreg Roach } 53591bb35acSGreg Roach 53691bb35acSGreg Roach while ($this->textWidthInPixels(' ' . $text . ' ') < $pixels) { 53791bb35acSGreg Roach $text = ' ' . $text . ' '; 53891bb35acSGreg Roach } 53991bb35acSGreg Roach 54091bb35acSGreg Roach // We only need the leading spaces. 54191bb35acSGreg Roach return rtrim($text); 54291bb35acSGreg Roach } 54391bb35acSGreg Roach 54491bb35acSGreg Roach /** 54591bb35acSGreg Roach * @param string $text 54691bb35acSGreg Roach * 54791bb35acSGreg Roach * @return int 54891bb35acSGreg Roach */ 54991bb35acSGreg Roach protected function textWidthInPixels(string $text): int 55091bb35acSGreg Roach { 55129033689SGreg Roach // If PHP is compiled with --enable-gd-jis-conv, then the function 55229033689SGreg Roach // imagettftext() is modified to expect EUC-JP encoding instead of UTF-8. 55329033689SGreg Roach // Attempt to detect and convert... 55429033689SGreg Roach if (gd_info()['JIS-mapped Japanese Font Support'] ?? false) { 55529033689SGreg Roach $text = mb_convert_encoding($text, 'EUC-JP', 'UTF-8'); 55629033689SGreg Roach } 55729033689SGreg Roach 55891bb35acSGreg Roach $bounding_box = imagettfbbox(self::TEXT_SIZE_POINTS, 0, self::FONT, $text); 55991bb35acSGreg Roach 56091bb35acSGreg Roach return $bounding_box[4] - $bounding_box[0]; 56191bb35acSGreg Roach } 562168ff6f3Sric2016} 563