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:
@@ -23,3 +23,69 @@ main.app-main {
|
|||||||
table.dataTable {
|
table.dataTable {
|
||||||
width: 100% !important;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Controller;
|
|||||||
use App\Entity\Npc;
|
use App\Entity\Npc;
|
||||||
use App\Form\NpcGameNotesType;
|
use App\Form\NpcGameNotesType;
|
||||||
use App\Form\NpcType;
|
use App\Form\NpcType;
|
||||||
|
use App\Npc\FamilyTreeBuilder;
|
||||||
use App\Repository\NpcRepository;
|
use App\Repository\NpcRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
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.
|
* In-play detail page: everything read-only, plus the game-notes field.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">&</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">← 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 %}
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<a href="{{ path('app_npcs') }}" class="text-decoration-none small">← All NPC's</a>
|
<a href="{{ path('app_npcs') }}" class="text-decoration-none small">← All NPC's</a>
|
||||||
<h1 class="h3 mb-0">{{ npc.name }}</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ path('app_npc_edit', {id: npc.id}) }}" class="btn btn-outline-secondary btn-sm">Edit details</a>
|
<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-header">Details</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<dl class="row mb-0">
|
<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">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">Race</dt><dd class="col-7">{{ npc.race.value }}</dd>
|
||||||
<dt class="col-5">Occupation</dt><dd class="col-7">{{ npc.occupation ?? '—' }}</dd>
|
<dt class="col-5">Occupation</dt><dd class="col-7">{{ npc.occupation ?? '—' }}</dd>
|
||||||
|
|||||||
Reference in New Issue
Block a user