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
+66
View File
@@ -23,3 +23,69 @@ main.app-main {
table.dataTable {
width: 100% !important;
}
/* --- Family tree --------------------------------------------------------- */
.family-tree,
.family-tree ul {
list-style: none;
margin: 0;
padding-left: 1.25rem;
}
.family-tree {
padding-left: 0;
}
.family-tree li {
position: relative;
padding: 0.15rem 0 0.15rem 1rem;
}
.family-tree ul > li::before { /* elbow connector */
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 50%;
width: 0.75rem;
border-left: 1px solid var(--bs-border-color);
border-bottom: 1px solid var(--bs-border-color);
}
.family-tree ul > li::after { /* line continuing to siblings below */
content: "";
position: absolute;
top: 0;
left: 0;
height: 100%;
border-left: 1px solid var(--bs-border-color);
}
.family-tree ul > li:last-child::after {
display: none;
}
.ft-couple {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.ft-person {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.2rem 0.5rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
background: var(--bs-body-bg);
}
.ft-person:hover {
border-color: var(--bs-primary);
}
.ft-name {
font-weight: 500;
}
.ft-meta {
font-size: 0.8125rem;
color: var(--bs-secondary-color);
}
.ft-amp {
color: var(--bs-secondary-color);
}
+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;
}
}
+50
View File
@@ -0,0 +1,50 @@
{% extends 'base.html.twig' %}
{% block title %}{{ family }} family · NPC's · dmtools{% endblock %}
{% macro person(npc, muted) %}
<a class="ft-person{{ muted ? ' text-body-secondary' : '' }}" href="{{ path('app_npc_show', {id: npc.id}) }}">
<img src="{{ asset(npc.portraitPath) }}" alt="" width="34" height="42"
class="rounded object-fit-cover" onerror="this.remove()">
<span class="ft-name">{{ npc.name }}</span>
<span class="ft-meta">{{ npc.age ?? '?' }}{% if npc.occupation %} · {{ npc.occupation }}{% endif %}</span>
</a>
{% endmacro %}
{% macro node(n) %}
{% import _self as tree %}
<li>
<div class="ft-couple">
{{ tree.person(n.npc) }}
{% if n.spouse %}
<span class="ft-amp">&amp;</span>
{{ tree.person(n.spouse) }}
{% endif %}
</div>
{% if n.children %}
<ul>
{% for c in n.children %}{{ tree.node(c) }}{% endfor %}
</ul>
{% endif %}
</li>
{% endmacro %}
{% import _self as tree %}
{% block body %}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<a href="{{ path('app_npcs') }}" class="text-decoration-none small">&larr; All NPC's</a>
<h1 class="h3 mb-0">The {{ family }} family</h1>
<span class="text-body-secondary">{{ count }} member{{ count == 1 ? '' : 's' }}</span>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<ul class="family-tree">
{% for r in roots %}{{ tree.node(r) }}{% endfor %}
</ul>
</div>
</div>
{% endblock %}
+5 -2
View File
@@ -12,7 +12,9 @@
<div>
<a href="{{ path('app_npcs') }}" class="text-decoration-none small">&larr; All NPC's</a>
<h1 class="h3 mb-0">{{ npc.name }}</h1>
<span class="text-body-secondary">{{ npc.family }}{% if npc.occupation %} · {{ npc.occupation }}{% endif %}</span>
<span class="text-body-secondary">
<a href="{{ path('app_npc_family', {family: npc.family}) }}">{{ npc.family }}</a>{% if npc.occupation %} · {{ npc.occupation }}{% endif %}
</span>
</div>
</div>
<a href="{{ path('app_npc_edit', {id: npc.id}) }}" class="btn btn-outline-secondary btn-sm">Edit details</a>
@@ -24,7 +26,8 @@
<div class="card-header">Details</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5">Family</dt><dd class="col-7">{{ npc.family }}</dd>
<dt class="col-5">Family</dt>
<dd class="col-7"><a href="{{ path('app_npc_family', {family: npc.family}) }}">{{ npc.family }}</a> family tree</dd>
<dt class="col-5">Age</dt><dd class="col-7">{{ npc.age ?? '—' }}</dd>
<dt class="col-5">Race</dt><dd class="col-7">{{ npc.race.value }}</dd>
<dt class="col-5">Occupation</dt><dd class="col-7">{{ npc.occupation ?? '—' }}</dd>