Add family tree page, linked from the NPC's family name

- /npcs/family/{family} (app_npc_family) renders a parent->child tree for
  everyone sharing that family name; co-parents who are both in the family
  are paired into one couple node, elders first
- FamilyTreeBuilder does the flat-list -> tree assembly
- the NPC detail page's family name (subtitle + Details row) now links there
- .family-tree CSS: nested list with connector lines, small portrait avatars

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 16:23:03 +02:00
co-authored by Claude Sonnet 5
parent d43746970b
commit 574d903715
5 changed files with 229 additions and 2 deletions
+19
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Entity\Npc;
use App\Form\NpcGameNotesType;
use App\Form\NpcType;
use App\Npc\FamilyTreeBuilder;
use App\Repository\NpcRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -66,6 +67,24 @@ class NpcController extends AbstractController
]);
}
/**
* Family tree for one family name.
*/
#[Route('/npcs/family/{family}', name: 'app_npc_family', requirements: ['family' => '[^/]+'], methods: ['GET'])]
public function family(string $family, NpcRepository $repo, FamilyTreeBuilder $trees): Response
{
$members = $repo->findBy(['family' => $family], ['name' => 'ASC']);
if (!$members) {
throw $this->createNotFoundException(sprintf('No NPCs in family "%s".', $family));
}
return $this->render('npc/family.html.twig', [
'family' => $family,
'count' => \count($members),
'roots' => $trees->build($members),
]);
}
/**
* In-play detail page: everything read-only, plus the game-notes field.
*/
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Npc;
use App\Entity\Npc;
/**
* Turns a flat list of NPCs (one family) into a parent -> child tree,
* pairing co-parents who are both in the family into one couple node.
*
* A node is: array{npc: Npc, spouse: Npc|null, children: list<node>}
*/
final class FamilyTreeBuilder
{
/**
* @param list<Npc> $members
*
* @return list<array{npc: Npc, spouse: Npc|null, children: list<array>}>
*/
public function build(array $members): array
{
/** @var array<int, Npc> $byId */
$byId = [];
foreach ($members as $m) {
$byId[$m->getId()] = $m;
}
$inFamily = static fn (?Npc $n): bool => null !== $n && isset($byId[$n->getId()]);
// children under every in-family parent; couples via shared children
$childrenOf = [];
$spouseOf = [];
foreach ($members as $m) {
$f = $m->getFather();
$mo = $m->getMother();
foreach ([$f, $mo] as $p) {
if ($inFamily($p)) {
$childrenOf[$p->getId()][$m->getId()] = $m;
}
}
if ($inFamily($f) && $inFamily($mo)) {
$spouseOf[$f->getId()] = $mo;
$spouseOf[$mo->getId()] = $f;
}
}
$done = []; // ids emitted anywhere in the tree
$build = function (Npc $npc) use (&$build, $childrenOf, $spouseOf, $byId, &$done): array {
$done[$npc->getId()] = true;
$spouse = $spouseOf[$npc->getId()] ?? null;
if ($spouse) {
$done[$spouse->getId()] = true;
}
$kids = ($childrenOf[$npc->getId()] ?? []) + ($spouse ? ($childrenOf[$spouse->getId()] ?? []) : []);
uasort($kids, static fn (Npc $a, Npc $b) => ($b->getAge() ?? 0) <=> ($a->getAge() ?? 0));
$children = [];
foreach ($kids as $kid) {
if (!isset($done[$kid->getId()])) {
$children[] = $build($kid);
}
}
return ['npc' => $npc, 'spouse' => $spouse, 'children' => $children];
};
// Elders first: members with no in-family parent, oldest first.
usort($members, static fn (Npc $a, Npc $b) => ($b->getAge() ?? 0) <=> ($a->getAge() ?? 0));
$roots = [];
foreach ($members as $m) {
if (isset($done[$m->getId()])) {
continue;
}
if ($inFamily($m->getFather()) || $inFamily($m->getMother())) {
continue;
}
$roots[] = $build($m);
}
// Anyone still unplaced (odd data): show as their own root.
foreach ($members as $m) {
if (!isset($done[$m->getId()])) {
$roots[] = $build($m);
}
}
return $roots;
}
}