diff --git a/assets/styles/app.css b/assets/styles/app.css index f5b2825..6751a2a 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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); +} diff --git a/src/Controller/NpcController.php b/src/Controller/NpcController.php index 58ce4e8..b7dc5a2 100644 --- a/src/Controller/NpcController.php +++ b/src/Controller/NpcController.php @@ -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. */ diff --git a/src/Npc/FamilyTreeBuilder.php b/src/Npc/FamilyTreeBuilder.php new file mode 100644 index 0000000..8ee5e3d --- /dev/null +++ b/src/Npc/FamilyTreeBuilder.php @@ -0,0 +1,89 @@ + 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} + */ +final class FamilyTreeBuilder +{ + /** + * @param list $members + * + * @return list}> + */ + public function build(array $members): array + { + /** @var array $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; + } +} diff --git a/templates/npc/family.html.twig b/templates/npc/family.html.twig new file mode 100644 index 0000000..a9474a7 --- /dev/null +++ b/templates/npc/family.html.twig @@ -0,0 +1,50 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ family }} family · NPC's · dmtools{% endblock %} + +{% macro person(npc, muted) %} + + + {{ npc.name }} + {{ npc.age ?? '?' }}{% if npc.occupation %} · {{ npc.occupation }}{% endif %} + +{% endmacro %} + +{% macro node(n) %} + {% import _self as tree %} +
  • +
    + {{ tree.person(n.npc) }} + {% if n.spouse %} + & + {{ tree.person(n.spouse) }} + {% endif %} +
    + {% if n.children %} +
      + {% for c in n.children %}{{ tree.node(c) }}{% endfor %} +
    + {% endif %} +
  • +{% endmacro %} + +{% import _self as tree %} + +{% block body %} +
    +
    + ← All NPC's +

    The {{ family }} family

    + {{ count }} member{{ count == 1 ? '' : 's' }} +
    +
    + +
    +
    +
      + {% for r in roots %}{{ tree.node(r) }}{% endfor %} +
    +
    +
    +{% endblock %} diff --git a/templates/npc/show.html.twig b/templates/npc/show.html.twig index 7bff289..88a6015 100644 --- a/templates/npc/show.html.twig +++ b/templates/npc/show.html.twig @@ -12,7 +12,9 @@
    ← All NPC's

    {{ npc.name }}

    - {{ npc.family }}{% if npc.occupation %} · {{ npc.occupation }}{% endif %} + + {{ npc.family }}{% if npc.occupation %} · {{ npc.occupation }}{% endif %} +
    Edit details @@ -24,7 +26,8 @@
    Details
    -
    Family
    {{ npc.family }}
    +
    Family
    +
    {{ npc.family }} family tree
    Age
    {{ npc.age ?? '—' }}
    Race
    {{ npc.race.value }}
    Occupation
    {{ npc.occupation ?? '—' }}