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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user