Add "more descendants" indicator and fix indicator draw order

Show small child-box indicators at bottom-right of person cards when
descendants exist beyond the current view. Fix both ancestor and
descendant indicators to draw connecting lines behind boxes.
This commit is contained in:
2026-03-14 20:37:42 +01:00
parent 272ee41df6
commit 7528d30de1
17 changed files with 2187 additions and 21 deletions
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace FullDiagram;
class Configuration
{
public function __construct(
private readonly int $ancestorGenerations = 3,
private readonly int $descendantGenerations = 3,
private readonly bool $showSiblings = true,
) {
}
public function ancestorGenerations(): int
{
return $this->ancestorGenerations;
}
public function descendantGenerations(): int
{
return $this->descendantGenerations;
}
public function showSiblings(): bool
{
return $this->showSiblings;
}
}
@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace FullDiagram\Facade;
use Fisharebest\Webtrees\Individual;
use FullDiagram\Configuration;
/**
* Builds a flat person array suitable for the family-chart library.
*
* Phase 1: Traverse the tree (ancestors + descendants) to collect individuals.
* Phase 2: Build relationship data for each collected individual, filtering
* to only include relationships with other collected individuals.
*
* Output format per person (matches family-chart's expected structure):
* { id, data: { gender, "first name", "last name", ... }, rels: { parents, spouses, children } }
*/
class DataFacade
{
/** @var array<string, Individual> Collected individuals keyed by xref */
private array $individuals = [];
public function buildFullTree(Individual $root, Configuration $configuration): array
{
$this->individuals = [];
// Phase 1: Collect all individuals within configured depth
$this->collectPerson($root);
$this->collectAncestors($root, $configuration->ancestorGenerations(), $configuration->showSiblings());
$this->collectDescendants($root, $configuration->descendantGenerations());
// Phase 2: Build flat person array with bidirectional relationships
$persons = [];
foreach ($this->individuals as $individual) {
$persons[] = $this->buildPersonData($individual);
}
return [
'persons' => $persons,
'mainId' => $root->xref(),
];
}
private function collectPerson(Individual $individual): void
{
$this->individuals[$individual->xref()] = $individual;
}
private function collectAncestors(Individual $individual, int $generations, bool $showSiblings): void
{
if ($generations <= 0) {
return;
}
foreach ($individual->childFamilies() as $family) {
$husband = $family->husband();
$wife = $family->wife();
if ($husband !== null && !isset($this->individuals[$husband->xref()])) {
$this->collectPerson($husband);
$this->collectAncestors($husband, $generations - 1, $showSiblings);
}
if ($wife !== null && !isset($this->individuals[$wife->xref()])) {
$this->collectPerson($wife);
$this->collectAncestors($wife, $generations - 1, $showSiblings);
}
// Collect siblings (other children of this family)
if ($showSiblings) {
foreach ($family->children() as $child) {
if (!isset($this->individuals[$child->xref()])) {
$this->collectPerson($child);
// One generation of descendants for siblings
$this->collectDescendants($child, 1);
}
}
}
}
}
private function collectDescendants(Individual $individual, int $generations): void
{
if ($generations <= 0) {
return;
}
foreach ($individual->spouseFamilies() as $family) {
$spouse = $family->spouse($individual);
if ($spouse !== null && !isset($this->individuals[$spouse->xref()])) {
$this->collectPerson($spouse);
}
foreach ($family->children() as $child) {
if (!isset($this->individuals[$child->xref()])) {
$this->collectPerson($child);
$this->collectDescendants($child, $generations - 1);
}
}
}
}
/**
* Build a single person entry in family-chart format.
*
* Relationships are filtered to only include collected individuals,
* ensuring the graph is self-consistent.
*/
private function buildPersonData(Individual $individual): array
{
$xref = $individual->xref();
// Relationships — only to other collected individuals
$parents = [];
$spouses = [];
$children = [];
// Parents: from childFamilies
foreach ($individual->childFamilies() as $family) {
$husband = $family->husband();
$wife = $family->wife();
if ($husband !== null && isset($this->individuals[$husband->xref()])) {
$parents[] = $husband->xref();
}
if ($wife !== null && isset($this->individuals[$wife->xref()])) {
$parents[] = $wife->xref();
}
}
// Spouses and children: from spouseFamilies
foreach ($individual->spouseFamilies() as $family) {
$spouse = $family->spouse($individual);
if ($spouse !== null && isset($this->individuals[$spouse->xref()])) {
$spouses[] = $spouse->xref();
}
foreach ($family->children() as $child) {
if (isset($this->individuals[$child->xref()])) {
$children[] = $child->xref();
}
}
}
// Extract personal data
$names = $individual->getAllNames();
$primaryName = $names[0] ?? [];
$firstName = self::cleanGedcomName(trim($primaryName['givn'] ?? ''));
$lastName = self::cleanGedcomName(trim($primaryName['surn'] ?? ''));
$thumbnailUrl = '';
$media = $individual->findHighlightedMediaFile();
if ($media !== null) {
$thumbnailUrl = $media->imageUrl(80, 80, 'crop');
}
// Marriage date from first spouse family
$marriageDate = '';
$spouseFamily = $individual->spouseFamilies()->first();
if ($spouseFamily !== null) {
$marriageFact = $spouseFamily->facts(['MARR'])->first();
if ($marriageFact !== null && $marriageFact->date()->isOK()) {
$marriageDate = strip_tags($marriageFact->date()->display());
}
}
// Check for ancestors/descendants beyond the current view
$hasMoreAncestors = false;
foreach ($individual->childFamilies() as $family) {
if (($family->husband() !== null && !isset($this->individuals[$family->husband()->xref()])) ||
($family->wife() !== null && !isset($this->individuals[$family->wife()->xref()]))) {
$hasMoreAncestors = true;
break;
}
}
$hasMoreDescendants = false;
foreach ($individual->spouseFamilies() as $family) {
foreach ($family->children() as $child) {
if (!isset($this->individuals[$child->xref()])) {
$hasMoreDescendants = true;
break 2;
}
}
}
return [
'id' => $xref,
'data' => [
'gender' => $individual->sex() === 'M' ? 'M' : 'F',
'first name' => $firstName,
'last name' => $lastName,
'fullName' => str_replace('@N.N.', "\u{2026}", strip_tags($individual->fullName())),
'birthDate' => self::extractDate($individual, 'BIRT'),
'birthYear' => self::extractYear($individual, 'BIRT'),
'birthPlace' => self::extractPlace($individual, 'BIRT'),
'deathDate' => self::extractDate($individual, 'DEAT'),
'deathYear' => self::extractYear($individual, 'DEAT'),
'deathPlace' => self::extractPlace($individual, 'DEAT'),
'baptismDate' => self::extractDate($individual, 'BAPM') ?: self::extractDate($individual, 'CHR'),
'marriageDate' => $marriageDate,
'occupation' => self::extractFactValue($individual, 'OCCU'),
'residence' => self::extractFactValue($individual, 'RESI'),
'isDead' => $individual->isDead(),
'avatar' => $thumbnailUrl,
'url' => $individual->url(),
'hasMoreAncestors' => $hasMoreAncestors,
'hasMoreDescendants' => $hasMoreDescendants,
],
'rels' => [
'parents' => array_values(array_unique($parents)),
'spouses' => array_values(array_unique($spouses)),
'children' => array_values(array_unique($children)),
],
];
}
private static function cleanGedcomName(string $name): string
{
if (preg_match('/^@[A-Z]\.N\.$/', $name)) {
return '';
}
return $name;
}
private static function extractDate(Individual $individual, string $tag): string
{
$fact = $individual->facts([$tag])->first();
if ($fact === null || !$fact->date()->isOK()) {
return '';
}
return strip_tags($fact->date()->display());
}
private static function extractYear(Individual $individual, string $tag): string
{
$fact = $individual->facts([$tag])->first();
if ($fact === null || !$fact->date()->isOK()) {
return '';
}
return (string) $fact->date()->minimumDate()->year();
}
private static function extractPlace(Individual $individual, string $tag): string
{
$fact = $individual->facts([$tag])->first();
if ($fact === null) {
return '';
}
return $fact->place()->gedcomName();
}
private static function extractFactValue(Individual $individual, string $tag): string
{
$fact = $individual->facts([$tag])->first();
if ($fact === null) {
return '';
}
return trim($fact->value());
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace FullDiagram\Model;
use JsonSerializable;
class FamilyNode implements JsonSerializable
{
/**
* @param NodeData|null $spouse
* @param list<NodeData> $children
* @param string $familyXref
* @param list<NodeData> $parents Used in ancestor context (both parents)
*/
public function __construct(
private readonly ?NodeData $spouse,
private readonly array $children = [],
private readonly string $familyXref = '',
private readonly array $parents = [],
) {
}
public function jsonSerialize(): mixed
{
$data = [
'familyXref' => $this->familyXref,
'spouse' => $this->spouse,
'children' => $this->children,
];
if ($this->parents !== []) {
$data['parents'] = $this->parents;
}
return $data;
}
}
+228
View File
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace FullDiagram\Model;
use Fisharebest\Webtrees\Individual;
use JsonSerializable;
class NodeData implements JsonSerializable
{
private string $xref;
private string $firstName;
private string $lastName;
private string $fullName;
private string $sex;
private string $birthDate;
private string $birthYear;
private string $birthPlace;
private string $deathDate;
private string $deathYear;
private string $deathPlace;
private string $baptismDate;
private string $marriageDate;
private string $occupation;
private string $residence;
private bool $isDead;
private bool $hasMoreAncestors = false;
private bool $hasMoreDescendants = false;
private string $thumbnailUrl;
private string $url;
private bool $isSibling;
private bool $isRoot;
/** @var list<FamilyNode> Parent families (ancestor direction) */
private array $parentFamilies = [];
/** @var list<FamilyNode> Spouse families (descendant direction) */
private array $families = [];
/** @param list<FamilyNode> $parentFamilies */
public function setParentFamilies(array $parentFamilies): void
{
$this->parentFamilies = $parentFamilies;
}
private function __construct()
{
}
public static function fromIndividual(Individual $individual, bool $isSibling = false, bool $isRoot = false): self
{
$node = new self();
$node->xref = $individual->xref();
$node->fullName = str_replace('@N.N.', "\u{2026}", strip_tags($individual->fullName()));
$node->sex = $individual->sex();
$node->isDead = $individual->isDead();
$node->thumbnailUrl = self::extractThumbnail($individual);
$node->url = $individual->url();
$node->isSibling = $isSibling;
$node->isRoot = $isRoot;
// Parse first/last name from GEDCOM name parts
// Filter out GEDCOM unknown-name placeholders like @N.N., @P.N.
$names = $individual->getAllNames();
$primaryName = $names[0] ?? [];
$node->firstName = self::cleanGedcomName(trim($primaryName['givn'] ?? ''));
$node->lastName = self::cleanGedcomName(trim($primaryName['surn'] ?? ''));
// Dates and places
$node->birthDate = self::extractDate($individual, 'BIRT');
$node->birthYear = self::extractYear($individual, 'BIRT');
$node->birthPlace = self::extractPlace($individual, 'BIRT');
$node->deathDate = self::extractDate($individual, 'DEAT');
$node->deathYear = self::extractYear($individual, 'DEAT');
$node->deathPlace = self::extractPlace($individual, 'DEAT');
$node->baptismDate = self::extractDate($individual, 'BAPM')
?: self::extractDate($individual, 'CHR');
$node->occupation = self::extractFactValue($individual, 'OCCU');
$node->residence = self::extractFactValue($individual, 'RESI');
// Marriage date from first spouse family
$node->marriageDate = '';
$spouseFamily = $individual->spouseFamilies()->first();
if ($spouseFamily !== null) {
$marriageFact = $spouseFamily->facts(['MARR'])->first();
if ($marriageFact !== null && $marriageFact->date()->isOK()) {
$node->marriageDate = strip_tags($marriageFact->date()->display());
}
}
return $node;
}
/**
* Replace GEDCOM unknown-name placeholders (@N.N., @P.N.) with empty string.
*/
private static function cleanGedcomName(string $name): string
{
// @N.N. = nomen nescio (unknown surname), @P.N. = praenomen nescio (unknown given name)
if (preg_match('/^@[A-Z]\.N\.$/', $name)) {
return '';
}
return $name;
}
private static function extractDate(Individual $individual, string $tag): string
{
$fact = $individual->facts([$tag])->first();
if ($fact === null) {
return '';
}
$date = $fact->date();
if (!$date->isOK()) {
return '';
}
return strip_tags($date->display());
}
private static function extractYear(Individual $individual, string $tag): string
{
$fact = $individual->facts([$tag])->first();
if ($fact === null) {
return '';
}
$date = $fact->date();
if (!$date->isOK()) {
return '';
}
return (string) $date->minimumDate()->year();
}
private static function extractPlace(Individual $individual, string $tag): string
{
$fact = $individual->facts([$tag])->first();
if ($fact === null) {
return '';
}
$place = $fact->place();
return $place->gedcomName();
}
private static function extractFactValue(Individual $individual, string $tag): string
{
$fact = $individual->facts([$tag])->first();
if ($fact === null) {
return '';
}
return trim($fact->value());
}
private static function extractThumbnail(Individual $individual): string
{
$media = $individual->findHighlightedMediaFile();
if ($media === null) {
return '';
}
return $media->imageUrl(80, 80, 'crop');
}
public function xref(): string
{
return $this->xref;
}
/** @param list<FamilyNode> $families */
public function setFamilies(array $families): void
{
$this->families = $families;
}
public function setHasMoreAncestors(bool $value): void
{
$this->hasMoreAncestors = $value;
}
public function setHasMoreDescendants(bool $value): void
{
$this->hasMoreDescendants = $value;
}
public function jsonSerialize(): mixed
{
$data = [
'xref' => $this->xref,
'firstName' => $this->firstName,
'lastName' => $this->lastName,
'fullName' => $this->fullName,
'sex' => $this->sex,
'birthDate' => $this->birthDate,
'birthYear' => $this->birthYear,
'birthPlace' => $this->birthPlace,
'deathDate' => $this->deathDate,
'deathYear' => $this->deathYear,
'deathPlace' => $this->deathPlace,
'baptismDate' => $this->baptismDate,
'marriageDate' => $this->marriageDate,
'occupation' => $this->occupation,
'residence' => $this->residence,
'isDead' => $this->isDead,
'thumbnailUrl' => $this->thumbnailUrl,
'url' => $this->url,
'isSibling' => $this->isSibling,
'isRoot' => $this->isRoot,
'hasMoreAncestors' => $this->hasMoreAncestors,
'hasMoreDescendants'=> $this->hasMoreDescendants,
];
if ($this->parentFamilies !== []) {
$data['parentFamilies'] = $this->parentFamilies;
}
if ($this->families !== []) {
$data['families'] = $this->families;
}
return $data;
}
}
+342
View File
@@ -0,0 +1,342 @@
<?php
/**
* Full Diagram module for webtrees.
*
* @license GPL-3.0-or-later
*/
declare(strict_types=1);
namespace FullDiagram;
use Fig\Http\Message\RequestMethodInterface;
use Fisharebest\Webtrees\Auth;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Menu;
use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Module\ModuleBlockInterface;
use Fisharebest\Webtrees\Module\ModuleBlockTrait;
use Fisharebest\Webtrees\Module\ModuleChartInterface;
use Fisharebest\Webtrees\Module\ModuleChartTrait;
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\Validator;
use Fisharebest\Webtrees\View;
use FullDiagram\Facade\DataFacade;
use Illuminate\Support\Str;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class Module extends AbstractModule implements ModuleChartInterface, ModuleCustomInterface, ModuleBlockInterface, RequestHandlerInterface
{
use ModuleChartTrait;
use ModuleCustomTrait;
use ModuleBlockTrait;
public const ROUTE_NAME = 'full-diagram';
public const ROUTE_URL = '/tree/{tree}/full-diagram/{xref}';
private const DEFAULT_ANCESTOR_GENERATIONS = 3;
private const DEFAULT_DESCENDANT_GENERATIONS = 3;
private const BLOCK_DEFAULT_ANCESTOR_GENS = 3;
private const BLOCK_DEFAULT_DESCENDANT_GENS = 3;
private const MINIMUM_GENERATIONS = 1;
private const MAXIMUM_GENERATIONS = 10;
public function title(): string
{
return I18N::translate('Full Diagram');
}
public function description(): string
{
return I18N::translate('An interactive diagram showing ancestors, descendants, and siblings.');
}
public function customModuleAuthorName(): string
{
return 'Alex';
}
public function customModuleVersion(): string
{
return '0.1.0';
}
public function customModuleSupportUrl(): string
{
return '';
}
public function resourcesFolder(): string
{
return __DIR__ . '/../resources/';
}
public function boot(): void
{
View::registerNamespace($this->name(), $this->resourcesFolder() . 'views/');
Registry::routeFactory()->routeMap()
->get(self::ROUTE_NAME, self::ROUTE_URL, $this)
->allows(RequestMethodInterface::METHOD_POST);
}
// ─── Translations ────────────────────────────────────────────────
public function customTranslations(string $language): array
{
$translations = [
'de' => [
'Full Diagram' => 'Vollständiges Diagramm',
'Full Diagram of %s' => 'Vollständiges Diagramm von %s',
'An interactive diagram showing ancestors, descendants, and siblings.' => 'Ein interaktives Diagramm mit Vorfahren, Nachkommen und Geschwistern.',
'Show siblings' => 'Geschwister anzeigen',
'Born' => 'Geboren',
'Baptism' => 'Taufe',
'Marriage' => 'Heirat',
'Died' => 'Gestorben',
'Occupation' => 'Beruf',
'Residence' => 'Wohnort',
'View profile' => 'Profil anzeigen',
'Died at age %s' => 'Gestorben im Alter von %s',
'Deceased' => 'Verstorben',
'Age ~%s' => 'Alter ~%s',
],
'nl' => [
'Full Diagram' => 'Volledig diagram',
'Full Diagram of %s' => 'Volledig diagram van %s',
'An interactive diagram showing ancestors, descendants, and siblings.' => 'Een interactief diagram met voorouders, nakomelingen en broers/zussen.',
'Show siblings' => 'Broers/zussen tonen',
'Born' => 'Geboren',
'Baptism' => 'Doop',
'Marriage' => 'Huwelijk',
'Died' => 'Overleden',
'Occupation' => 'Beroep',
'Residence' => 'Woonplaats',
'View profile' => 'Profiel bekijken',
'Died at age %s' => 'Overleden op %s-jarige leeftijd',
'Deceased' => 'Overleden',
'Age ~%s' => 'Leeftijd ~%s',
],
];
return $translations[$language] ?? [];
}
// ─── Chart interface ─────────────────────────────────────────────
public function chartMenuClass(): string
{
return 'menu-chart-full-diagram';
}
public function chartBoxMenu(Individual $individual): Menu|null
{
return $this->chartMenu($individual);
}
public function chartUrl(Individual $individual, array $parameters = []): string
{
return route(self::ROUTE_NAME, [
'tree' => $individual->tree()->name(),
'xref' => $individual->xref(),
] + $parameters);
}
public function chartTitle(Individual $individual): string
{
return I18N::translate('Full Diagram of %s', $individual->fullName());
}
// ─── Block interface ─────────────────────────────────────────────
public function isTreeBlock(): bool
{
return true;
}
public function isUserBlock(): bool
{
return true;
}
public function loadAjax(): bool
{
return true;
}
public function getBlock(Tree $tree, int $block_id, string $context, array $config = []): string
{
$PEDIGREE_ROOT_ID = $tree->getPreference('PEDIGREE_ROOT_ID');
$gedcomid = $tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF);
$default_xref = $gedcomid ?: $PEDIGREE_ROOT_ID;
$xref = $this->getBlockSetting($block_id, 'pid', $default_xref);
$ancestorGenerations = (int) $this->getBlockSetting($block_id, 'ancestor_generations', (string) self::BLOCK_DEFAULT_ANCESTOR_GENS);
$descendantGenerations = (int) $this->getBlockSetting($block_id, 'descendant_generations', (string) self::BLOCK_DEFAULT_DESCENDANT_GENS);
$showSiblings = $this->getBlockSetting($block_id, 'show_siblings', '1') === '1';
$individual = Registry::individualFactory()->make($xref, $tree);
if (!$individual instanceof Individual) {
$content = I18N::translate('You must select an individual and a chart type in the block preferences');
if ($context !== self::CONTEXT_EMBED) {
return view('modules/block-template', [
'block' => Str::kebab($this->name()),
'id' => $block_id,
'config_url' => $this->configUrl($tree, $context, $block_id),
'title' => $this->title(),
'content' => $content,
]);
}
return $content;
}
$individual = Auth::checkIndividualAccess($individual, false, true);
$configuration = new Configuration(
$ancestorGenerations,
$descendantGenerations,
$showSiblings,
);
$dataFacade = new DataFacade();
$treeData = $dataFacade->buildFullTree($individual, $configuration);
$title = $this->chartTitle($individual);
$content = view($this->name() . '::modules/full-diagram/block', [
'module' => $this,
'individual' => $individual,
'tree' => $tree,
'tree_data' => json_encode($treeData, JSON_THROW_ON_ERROR),
'javascript_url' => $this->assetUrl('js/full-diagram.min.js'),
'stylesheet_url' => $this->assetUrl('css/full-diagram.css'),
'block_id' => $block_id,
'ancestor_generations' => $ancestorGenerations,
'descendant_generations' => $descendantGenerations,
'show_siblings' => $showSiblings,
]);
if ($context !== self::CONTEXT_EMBED) {
return view('modules/block-template', [
'block' => Str::kebab($this->name()),
'id' => $block_id,
'config_url' => $this->configUrl($tree, $context, $block_id),
'title' => $title,
'content' => $content,
]);
}
return $content;
}
public function saveBlockConfiguration(ServerRequestInterface $request, int $block_id): void
{
$xref = Validator::parsedBody($request)->isXref()->string('xref');
$ancestorGenerations = Validator::parsedBody($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('ancestor_generations');
$descendantGenerations = Validator::parsedBody($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('descendant_generations');
$showSiblings = Validator::parsedBody($request)->string('show_siblings', '0');
$this->setBlockSetting($block_id, 'pid', $xref);
$this->setBlockSetting($block_id, 'ancestor_generations', (string) $ancestorGenerations);
$this->setBlockSetting($block_id, 'descendant_generations', (string) $descendantGenerations);
$this->setBlockSetting($block_id, 'show_siblings', $showSiblings === '1' ? '1' : '0');
}
public function editBlockConfiguration(Tree $tree, int $block_id): string
{
$PEDIGREE_ROOT_ID = $tree->getPreference('PEDIGREE_ROOT_ID');
$gedcomid = $tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF);
$default_xref = $gedcomid ?: $PEDIGREE_ROOT_ID;
$xref = $this->getBlockSetting($block_id, 'pid', $default_xref);
$ancestorGenerations = (int) $this->getBlockSetting($block_id, 'ancestor_generations', (string) self::BLOCK_DEFAULT_ANCESTOR_GENS);
$descendantGenerations = (int) $this->getBlockSetting($block_id, 'descendant_generations', (string) self::BLOCK_DEFAULT_DESCENDANT_GENS);
$showSiblings = $this->getBlockSetting($block_id, 'show_siblings', '1') === '1';
$individual = Registry::individualFactory()->make($xref, $tree);
return view($this->name() . '::modules/full-diagram/block-config', [
'individual' => $individual,
'tree' => $tree,
'ancestor_generations' => $ancestorGenerations,
'descendant_generations' => $descendantGenerations,
'show_siblings' => $showSiblings,
]);
}
// ─── Route handler ───────────────────────────────────────────────
public function handle(ServerRequestInterface $request): ResponseInterface
{
$tree = Validator::attributes($request)->tree();
$xref = Validator::attributes($request)->isXref()->string('xref');
$individual = Registry::individualFactory()->make($xref, $tree);
$individual = Auth::checkIndividualAccess($individual, false, true);
// Redirect POST to GET for clean URLs
if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
$params = (array) $request->getParsedBody();
return redirect($this->chartUrl($individual, [
'ancestor_generations' => $params['ancestor_generations'] ?? self::DEFAULT_ANCESTOR_GENERATIONS,
'descendant_generations' => $params['descendant_generations'] ?? self::DEFAULT_DESCENDANT_GENERATIONS,
'show_siblings' => $params['show_siblings'] ?? '1',
]));
}
$ancestorGenerations = Validator::queryParams($request)
->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)
->integer('ancestor_generations', self::DEFAULT_ANCESTOR_GENERATIONS);
$descendantGenerations = Validator::queryParams($request)
->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)
->integer('descendant_generations', self::DEFAULT_DESCENDANT_GENERATIONS);
$showSiblings = Validator::queryParams($request)
->string('show_siblings', '1') === '1';
// Check for AJAX request
$ajax = Validator::queryParams($request)->string('ajax', '') === '1';
$configuration = new Configuration(
$ancestorGenerations,
$descendantGenerations,
$showSiblings,
);
$dataFacade = new DataFacade();
$treeData = $dataFacade->buildFullTree($individual, $configuration);
if ($ajax) {
return response([
'data' => $treeData,
]);
}
return $this->viewResponse($this->name() . '::modules/full-diagram/page', [
'title' => $this->chartTitle($individual),
'individual' => $individual,
'module' => $this,
'tree' => $tree,
'configuration' => $configuration,
'tree_data' => json_encode($treeData, JSON_THROW_ON_ERROR),
'javascript_url' => $this->assetUrl('js/full-diagram.min.js'),
'stylesheet_url' => $this->assetUrl('css/full-diagram.css'),
'ancestor_generations' => $ancestorGenerations,
'descendant_generations' => $descendantGenerations,
'show_siblings' => $showSiblings,
'max_generations' => self::MAXIMUM_GENERATIONS,
'min_generations' => self::MINIMUM_GENERATIONS,
]);
}
}