Skip to content

Commit 2d0e817

Browse files
committed
feat: add FactResolver for chart-box additional facts
Mirrors the webtrees core chart-box template: BIRT-equivalent (BIRT/CHR/BAPM) always first, optional tags from the tree-level CHART_BOX_TAGS preference next, DEAT-equivalent (DEAT/BURI/CREM) last. Two entry points: - effectiveTags($showAdditional, $excludeTags) returns the slot list used to size the chart's uniform box height. - factsFor($individual, $showAdditional, $excludeTags) returns the per-person fact views aligned with effectiveTags() (null for missing events so positions stay stable across boxes). Callers pass excludeTags (e.g. ['MARR'] for ancestor-only charts like the pedigree-chart) and a per-chart 'Show additional facts' toggle. Refs magicsunday/webtrees-pedigree-chart#45.
1 parent 532e24d commit 2d0e817

2 files changed

Lines changed: 295 additions & 0 deletions

File tree

src/Processor/FactResolver.php

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<?php
2+
3+
/**
4+
* This file is part of the package magicsunday/webtrees-module-base.
5+
*
6+
* For the full copyright and license information, please read the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace MagicSunday\Webtrees\ModuleBase\Processor;
13+
14+
use Fisharebest\Webtrees\Fact;
15+
use Fisharebest\Webtrees\Gedcom;
16+
use Fisharebest\Webtrees\Individual;
17+
use Fisharebest\Webtrees\Tree;
18+
19+
/**
20+
* Resolves which facts to render in a chart's person boxes, mirroring the
21+
* webtrees core chart-box template: a BIRT-equivalent event (BIRT / CHR /
22+
* BAPM) is always shown, followed optionally by the tree-level CHART_BOX_TAGS
23+
* preference — the same list the core uses — and finally a DEAT-equivalent
24+
* event (DEAT / BURI / CREM).
25+
*
26+
* Callers pass {@see showAdditional()} from a per-chart form toggle so users
27+
* can globally hide the extra facts without editing tree settings. Callers
28+
* may also pass {@see excludeTags()} to remove inappropriate tags for a
29+
* given chart — e.g. the pedigree-chart is ancestors-only and filters MARR.
30+
*
31+
* @author Rico Sonntag <mail@ricosonntag.de>
32+
* @license https://opensource.org/licenses/GPL-3.0 GNU General Public License v3.0
33+
* @link https://github.com/magicsunday/webtrees-module-base/
34+
*/
35+
final readonly class FactResolver
36+
{
37+
public function __construct(
38+
private Tree $tree,
39+
) {
40+
}
41+
42+
/**
43+
* Returns the tag list actually rendered for each box in the chart, in
44+
* order: always BIRT-equivalent first, optionally the tree-configured
45+
* extras, always DEAT-equivalent last. This count drives the chart's
46+
* uniform box height so all boxes stay aligned even when a given person
47+
* lacks a particular fact.
48+
*
49+
* @param bool $showAdditional Whether the chart's "Show additional facts" toggle is on
50+
* @param list<string> $excludeTags Tags to filter out of the optional list (e.g. ['MARR'] for ancestor charts)
51+
*
52+
* @return list<string>
53+
*/
54+
public function effectiveTags(bool $showAdditional, array $excludeTags = []): array
55+
{
56+
// BIRT and DEAT come first (stacked directly under the name),
57+
// optional tags come after — the renderer may add a visual gap
58+
// between the date block and the optional fact block.
59+
$tags = [self::BIRTH_PLACEHOLDER, self::DEATH_PLACEHOLDER];
60+
61+
if ($showAdditional) {
62+
foreach ($this->optionalTags($excludeTags) as $tag) {
63+
$tags[] = $tag;
64+
}
65+
}
66+
67+
return $tags;
68+
}
69+
70+
/**
71+
* Returns the extracted fact views for one individual, aligned with
72+
* {@see effectiveTags()}. Positions where the individual has no
73+
* matching fact are returned as null so the renderer can reserve
74+
* space without drawing anything.
75+
*
76+
* @param Individual $individual
77+
* @param bool $showAdditional
78+
* @param list<string> $excludeTags
79+
*
80+
* @return list<array{tag: string, label: string, date: string, place: string, value: string}|null>
81+
*/
82+
public function factsFor(Individual $individual, bool $showAdditional, array $excludeTags = []): array
83+
{
84+
$views = [];
85+
86+
$views[] = $this->firstOfGroup($individual, Gedcom::BIRTH_EVENTS);
87+
$views[] = $this->firstOfGroup($individual, Gedcom::DEATH_EVENTS);
88+
89+
if ($showAdditional) {
90+
foreach ($this->optionalTags($excludeTags) as $tag) {
91+
$views[] = $this->firstWithTag($individual, $tag);
92+
}
93+
}
94+
95+
return $views;
96+
}
97+
98+
/**
99+
* Returns the tree-level optional tag list (CHART_BOX_TAGS) with BIRT-
100+
* and DEAT-equivalent tags filtered out, plus any caller-supplied
101+
* exclusions.
102+
*
103+
* @param list<string> $excludeTags
104+
*
105+
* @return list<string>
106+
*/
107+
public function optionalTags(array $excludeTags = []): array
108+
{
109+
preg_match_all('/\w+/', $this->tree->getPreference(self::PREFERENCE_CHART_BOX_TAGS), $matches);
110+
111+
$always = array_merge(Gedcom::BIRTH_EVENTS, Gedcom::DEATH_EVENTS);
112+
113+
return array_values(array_filter(
114+
$matches[0],
115+
static fn (string $tag): bool => !in_array($tag, $always, true)
116+
&& !in_array($tag, $excludeTags, true)
117+
));
118+
}
119+
120+
/**
121+
* @param list<string> $group
122+
*
123+
* @return array{tag: string, label: string, date: string, place: string, value: string}|null
124+
*/
125+
private function firstOfGroup(Individual $individual, array $group): ?array
126+
{
127+
foreach ($group as $tag) {
128+
$view = $this->firstWithTag($individual, $tag);
129+
130+
if ($view !== null) {
131+
return $view;
132+
}
133+
}
134+
135+
return null;
136+
}
137+
138+
/**
139+
* @return array{tag: string, label: string, date: string, place: string, value: string}|null
140+
*/
141+
private function firstWithTag(Individual $individual, string $tag): ?array
142+
{
143+
$fact = $individual->facts([$tag])->first();
144+
145+
if (!$fact instanceof Fact) {
146+
return null;
147+
}
148+
149+
return [
150+
'tag' => $tag,
151+
'label' => $fact->label(),
152+
'date' => strip_tags($fact->date()->display()),
153+
'place' => strip_tags($fact->place()->shortName()),
154+
'value' => strip_tags($fact->value()),
155+
];
156+
}
157+
158+
/**
159+
* Placeholder used in {@see effectiveTags()} so the BIRT row's position
160+
* is preserved regardless of which concrete birth-equivalent tag each
161+
* individual has.
162+
*/
163+
public const string BIRTH_PLACEHOLDER = 'BIRT';
164+
165+
/**
166+
* Placeholder used in {@see effectiveTags()} so the DEAT row's position
167+
* is preserved regardless of which concrete death-equivalent tag each
168+
* individual has.
169+
*/
170+
public const string DEATH_PLACEHOLDER = 'DEAT';
171+
172+
/**
173+
* Name of the tree-level preference holding the optional-tag CSV.
174+
*/
175+
private const string PREFERENCE_CHART_BOX_TAGS = 'CHART_BOX_TAGS';
176+
}

tests/FactResolverTest.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
/**
4+
* This file is part of the package magicsunday/webtrees-module-base.
5+
*
6+
* For the full copyright and license information, please read the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace MagicSunday\Webtrees\ModuleBase\Test;
13+
14+
use Fisharebest\Webtrees\Tree;
15+
use MagicSunday\Webtrees\ModuleBase\Processor\FactResolver;
16+
use PHPUnit\Framework\Attributes\Test;
17+
use PHPUnit\Framework\TestCase;
18+
19+
/**
20+
* FactResolverTest.
21+
*
22+
* @author Rico Sonntag <mail@ricosonntag.de>
23+
* @license https://opensource.org/licenses/GPL-3.0 GNU General Public License v3.0
24+
* @link https://github.com/magicsunday/webtrees-module-base/
25+
*/
26+
class FactResolverTest extends TestCase
27+
{
28+
/**
29+
* Builds a FactResolver backed by a Tree stub that returns the given
30+
* string for the CHART_BOX_TAGS preference.
31+
*/
32+
private function resolverWithTags(string $chartBoxTags): FactResolver
33+
{
34+
$tree = self::createStub(Tree::class);
35+
$tree->method('getPreference')->willReturn($chartBoxTags);
36+
37+
return new FactResolver($tree);
38+
}
39+
40+
#[Test]
41+
public function optionalTagsReturnsEntriesFromCsvPreference(): void
42+
{
43+
$resolver = $this->resolverWithTags('OCCU,RESI,MARR');
44+
45+
self::assertSame(['OCCU', 'RESI', 'MARR'], $resolver->optionalTags());
46+
}
47+
48+
#[Test]
49+
public function optionalTagsHandlesWhitespaceAndMixedSeparators(): void
50+
{
51+
$resolver = $this->resolverWithTags(" OCCU , RESI\nMARR ");
52+
53+
self::assertSame(['OCCU', 'RESI', 'MARR'], $resolver->optionalTags());
54+
}
55+
56+
#[Test]
57+
public function optionalTagsStripsBirthAndDeathEquivalents(): void
58+
{
59+
// BIRT, CHR, BAPM, DEAT, BURI, CREM are always rendered in their
60+
// dedicated positions; they must not appear in the optional list.
61+
$resolver = $this->resolverWithTags('BIRT,CHR,BAPM,OCCU,DEAT,BURI,CREM,RESI');
62+
63+
self::assertSame(['OCCU', 'RESI'], $resolver->optionalTags());
64+
}
65+
66+
#[Test]
67+
public function optionalTagsHonoursCallerExcludes(): void
68+
{
69+
// Ancestor-only charts pass ['MARR'] so couples' marriages do not
70+
// bleed into an individual ancestor's box.
71+
$resolver = $this->resolverWithTags('OCCU,RESI,MARR');
72+
73+
self::assertSame(['OCCU', 'RESI'], $resolver->optionalTags(['MARR']));
74+
}
75+
76+
#[Test]
77+
public function effectiveTagsPutsBirthAndDeathFirstThenOptionalList(): void
78+
{
79+
$resolver = $this->resolverWithTags('OCCU,RESI');
80+
81+
self::assertSame(
82+
[FactResolver::BIRTH_PLACEHOLDER, FactResolver::DEATH_PLACEHOLDER, 'OCCU', 'RESI'],
83+
$resolver->effectiveTags(true)
84+
);
85+
}
86+
87+
#[Test]
88+
public function effectiveTagsOmitsOptionalListWhenShowAdditionalIsFalse(): void
89+
{
90+
$resolver = $this->resolverWithTags('OCCU,RESI,MARR');
91+
92+
self::assertSame(
93+
[FactResolver::BIRTH_PLACEHOLDER, FactResolver::DEATH_PLACEHOLDER],
94+
$resolver->effectiveTags(false)
95+
);
96+
}
97+
98+
#[Test]
99+
public function effectiveTagsAppliesExcludesToOptionalList(): void
100+
{
101+
$resolver = $this->resolverWithTags('OCCU,MARR,RESI');
102+
103+
self::assertSame(
104+
[FactResolver::BIRTH_PLACEHOLDER, FactResolver::DEATH_PLACEHOLDER, 'OCCU', 'RESI'],
105+
$resolver->effectiveTags(true, ['MARR'])
106+
);
107+
}
108+
109+
#[Test]
110+
public function effectiveTagsReturnsMinimalLayoutForEmptyPreference(): void
111+
{
112+
$resolver = $this->resolverWithTags('');
113+
114+
self::assertSame(
115+
[FactResolver::BIRTH_PLACEHOLDER, FactResolver::DEATH_PLACEHOLDER],
116+
$resolver->effectiveTags(true)
117+
);
118+
}
119+
}

0 commit comments

Comments
 (0)