Fix kin-distance metric: shortest descent from direct lineage

Replaces the previous "depth in generations along the strict
lineal chain" definition (which excluded siblings, aunts, cousins
entirely) with the metric the user actually wants: the number of
descent-steps separating the target from the recipient's closest
direct ancestor or descendant.

Examples relative to the recipient:
- sibling:        1  (parent → sibling)
- great-aunt:     1  (great-grandparent → great-aunt)
- nephew:         2  (parent → sibling → nephew)
- first cousin:   2  (grandparent → aunt → cousin)
- second cousin:  3
- ego, parents, grandparents, ..., children, ..., great-greats: 0
- own spouse, step-parents, brothers-in-law: inherit partner's
  distance (so spouse-of-distance-1 is also distance 1)

Implementation:
- Anchor set seeded with R's direct ancestors + R + direct
  descendants (capped at 25 generations to bound runaway data).
- Multi-source BFS expanding by descent only.
- Spouse propagation at every level so a person and their
  spouse always share the same distance.
- Memoised per (recipient xref, max distance).

Tree preference key and range kept (NEWSLETTER_LINEAL_DEPTH,
0–10, default 3); only the semantics and the user-facing label
+ help text change, with concrete examples in both English and
German.
This commit is contained in:
2026-05-15 13:10:38 +02:00
parent ff743e484f
commit 105b09c4c5
4 changed files with 111 additions and 41 deletions
+2 -2
View File
@@ -132,7 +132,7 @@ use Illuminate\Support\Collection;
<div class="row mb-3"> <div class="row mb-3">
<label class="col-sm-3 col-form-label" for="lineal-<?= $id ?>"> <label class="col-sm-3 col-form-label" for="lineal-<?= $id ?>">
<?= I18N::translate('Detailed view depth (generations)') ?> <?= I18N::translate('Detailed view distance') ?>
</label> </label>
<div class="col-sm-9"> <div class="col-sm-9">
<input class="form-control" type="number" style="max-width: 18rem;" <input class="form-control" type="number" style="max-width: 18rem;"
@@ -141,7 +141,7 @@ use Illuminate\Support\Collection;
min="<?= Configuration::MIN_LINEAL_DEPTH ?>" min="<?= Configuration::MIN_LINEAL_DEPTH ?>"
max="<?= Configuration::MAX_LINEAL_DEPTH ?>" required> max="<?= Configuration::MAX_LINEAL_DEPTH ?>" required>
<small class="form-text text-muted"> <small class="form-text text-muted">
<?= I18N::translate('Recipients see profile pictures, icons and the timeline only for their own direct ancestors and descendants within this many generations. Everyone else appears as a compact text list at the bottom of each section. Set to 0 to render the whole newsletter as text. Recipients with no linked tree record always see the full detailed view.') ?> <?= I18N::translate('A person is shown in detail (avatar, icon, timeline) when they sit within this many descent-steps of the recipient\'s direct lineage. Examples relative to the recipient: a sibling is distance 1 (one step down from the recipient\'s parent), a great-aunt is distance 1 (one step down from a great-grandparent), a nephew is distance 2, a first cousin is distance 2. Spouses share their partner\'s distance. Everyone outside this radius appears as a compact text bullet at the bottom of each section. Set to 0 to render the whole newsletter as text; recipients with no linked tree record always see the full detailed view.') ?>
</small> </small>
</div> </div>
</div> </div>
+3 -3
View File
@@ -125,9 +125,9 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
'Other birthdays' => 'Weitere Geburtstage', 'Other birthdays' => 'Weitere Geburtstage',
'Other anniversaries' => 'Weitere Hochzeitstage', 'Other anniversaries' => 'Weitere Hochzeitstage',
'Other historical events' => 'Weitere historische Ereignisse', 'Other historical events' => 'Weitere historische Ereignisse',
'Detailed view depth (generations)' => 'Detailansicht-Tiefe (Generationen)', 'Detailed view distance' => 'Detailansicht-Abstand',
'Recipients see profile pictures, icons and the timeline only for their own direct ancestors and descendants within this many generations. Everyone else appears as a compact text list at the bottom of each section. Set to 0 to render the whole newsletter as text. Recipients with no linked tree record always see the full detailed view.' 'A person is shown in detail (avatar, icon, timeline) when they sit within this many descent-steps of the recipient\'s direct lineage. Examples relative to the recipient: a sibling is distance 1 (one step down from the recipient\'s parent), a great-aunt is distance 1 (one step down from a great-grandparent), a nephew is distance 2, a first cousin is distance 2. Spouses share their partner\'s distance. Everyone outside this radius appears as a compact text bullet at the bottom of each section. Set to 0 to render the whole newsletter as text; recipients with no linked tree record always see the full detailed view.'
=> 'Empfänger sehen Profilbilder, Symbole und Zeitachse nur für ihre eigenen direkten Vorfahren und Nachkommen innerhalb dieser Generationenzahl. Alle anderen erscheinen als kompakte Textliste am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.', => 'Eine Person erscheint in der Detailansicht (Profilbild, Symbol, Zeitachse), wenn sie innerhalb dieser Anzahl Abstammungsschritte von der direkten Linie des Empfängers entfernt liegt. Beispiele bezogen auf den Empfänger: ein Geschwister hat Abstand 1 (ein Schritt abwärts vom Elternteil), eine Großtante hat Abstand 1 (ein Schritt abwärts vom Urgroßelternteil), ein Neffe hat Abstand 2, eine Cousine ersten Grades hat Abstand 2. Ehepartner teilen den Abstand ihres Partners. Alle außerhalb dieses Radius erscheinen als kompakte Textzeile am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.',
'%s birthday' => '%s Geburtstag', '%s birthday' => '%s Geburtstag',
'%s wedding anniversary' => '%s Hochzeitstag', '%s wedding anniversary' => '%s Hochzeitstag',
'Birthday' => 'Geburtstag', 'Birthday' => 'Geburtstag',
+3 -3
View File
@@ -311,13 +311,13 @@ final class NewsletterDispatchService
return array_fill_keys(array_keys($featured), true); return array_fill_keys(array_keys($featured), true);
} }
$depth = Configuration::linealDepth($tree); $max_distance = Configuration::linealDepth($tree);
$lineal_set = $this->relationship_finder->linealKin($self, $depth); $distances = $this->relationship_finder->kinDistances($self, $max_distance);
$detailed = []; $detailed = [];
foreach ($featured as $xref => $_individual) { foreach ($featured as $xref => $_individual) {
if (isset($lineal_set[$xref])) { if (isset($distances[$xref]) && $distances[$xref] <= $max_distance) {
$detailed[$xref] = true; $detailed[$xref] = true;
} }
} }
+103 -33
View File
@@ -33,8 +33,8 @@ final class RelationshipPathFinder
/** Default BFS depth — ~7 generations of ancestry / descent. */ /** Default BFS depth — ~7 generations of ancestry / descent. */
public const int DEFAULT_MAX_DEPTH = 14; public const int DEFAULT_MAX_DEPTH = 14;
/** @var array<string,array<string,true>> Cache: xref => {ancestor/descendant xref set} */ /** @var array<string,array<string,int>> Cache: "rootXref|maxDistance" => {xref => distance} */
private array $lineal_cache = []; private array $distance_cache = [];
/** @var array<string,string|null> Cache: "fromXref|toXref" -> label */ /** @var array<string,string|null> Cache: "fromXref|toXref" -> label */
private array $label_cache = []; private array $label_cache = [];
@@ -78,42 +78,112 @@ final class RelationshipPathFinder
} }
/** /**
* Set of xrefs that are direct ancestors or direct descendants of * Hard cap on how far up / down we'll walk when seeding the direct
* `$root` within `$depth` generations. * lineal anchor set. 25 generations is well beyond practical
* * genealogy and prevents pathological data from running away.
* Only lineal hops are followed (parent ↔ child) — spouses,
* siblings, and step-relations are deliberately excluded. The
* recipient's own xref is included in the set.
*
* The result is memoised: callers may invoke this once per
* recipient and reuse it across all featured xrefs.
*
* @return array<string,true>
*/ */
public function linealKin(Individual $root, int $depth): array private const int LINEAL_SEED_DEPTH = 25;
/**
* Distance from `$root` to every reachable kin within `$max_distance`,
* measured by the "branch length from the closest direct ancestor
* or descendant of `$root`" metric:
*
* 1. Take all of `$root`'s direct ancestors and descendants (and
* `$root` itself). These are *anchors* at distance 0.
* 2. Walk downward (child links only) from every anchor. Each
* hop increases distance by one — so e.g. sibling = 1
* (anchor = parent, one hop down), great-aunt = 1 (anchor =
* great-grandparent, one hop down to great-aunt), nephew = 2
* (anchor = parent → sibling → nephew), first cousin = 2.
* 3. At every level, a person's spouses inherit that level too —
* so brothers-in-law share the sibling's distance, and step-
* grandparents share grandparents' distance.
*
* The search stops at `$max_distance` to keep the result bounded.
*
* @return array<string,int> xref => smallest distance found
*/
public function kinDistances(Individual $root, int $max_distance): array
{ {
if ($depth < 0) { $cache_key = $root->xref() . '|' . $max_distance;
return [$root->xref() => true];
if (isset($this->distance_cache[$cache_key])) {
return $this->distance_cache[$cache_key];
} }
$cache_key = $root->xref() . '|' . $depth; // Anchors: every direct-line ancestor and descendant, plus root.
$anchors = [$root->xref() => $root];
$this->collectAncestors($root, self::LINEAL_SEED_DEPTH, $anchors);
$this->collectDescendants($root, self::LINEAL_SEED_DEPTH, $anchors);
if (isset($this->lineal_cache[$cache_key])) { /** @var array<string,int> $distance */
return $this->lineal_cache[$cache_key]; $distance = [];
/** @var array<string,Individual> $frontier */
$frontier = [];
foreach ($anchors as $xref => $individual) {
$distance[$xref] = 0;
$frontier[$xref] = $individual;
} }
$set = [$root->xref() => true]; // Spouses of anchors inherit distance 0 (so own spouse,
// step-parents, step-grandparents, ... all qualify).
$this->propagateSpouses($frontier, 0, $distance, $frontier);
$this->expandAncestors($root, $depth, $set); for ($d = 1; $d <= $max_distance && $frontier !== []; $d++) {
$this->expandDescendants($root, $depth, $set); $next = [];
return $this->lineal_cache[$cache_key] = $set; foreach ($frontier as $individual) {
foreach ($individual->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
$xref = $child->xref();
if (!isset($distance[$xref])) {
$distance[$xref] = $d;
$next[$xref] = $child;
}
}
}
}
// Spouses of new arrivals share their distance.
$this->propagateSpouses($next, $d, $distance, $next);
$frontier = $next;
}
return $this->distance_cache[$cache_key] = $distance;
} }
/** /**
* @param array<string,true> $set * For every individual in $sources, mark each of their spouses as
* being at $level (if not already labelled at a smaller distance)
* and add them to $into so subsequent iterations also descend from
* them.
*
* @param array<string,Individual> $sources
* @param array<string,int> $distance
* @param array<string,Individual> $into
*/ */
private function expandAncestors(Individual $indi, int $depth, array &$set): void private function propagateSpouses(array $sources, int $level, array &$distance, array &$into): void
{
foreach ($sources as $individual) {
foreach ($individual->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->spouses(Auth::PRIV_HIDE) as $spouse) {
$xref = $spouse->xref();
if (!isset($distance[$xref])) {
$distance[$xref] = $level;
$into[$xref] = $spouse;
}
}
}
}
}
/**
* @param array<string,Individual> $bag
*/
private function collectAncestors(Individual $indi, int $depth, array &$bag): void
{ {
if ($depth <= 0) { if ($depth <= 0) {
return; return;
@@ -121,18 +191,18 @@ final class RelationshipPathFinder
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) { foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) { foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
if (!isset($set[$parent->xref()])) { if (!isset($bag[$parent->xref()])) {
$set[$parent->xref()] = true; $bag[$parent->xref()] = $parent;
$this->expandAncestors($parent, $depth - 1, $set); $this->collectAncestors($parent, $depth - 1, $bag);
} }
} }
} }
} }
/** /**
* @param array<string,true> $set * @param array<string,Individual> $bag
*/ */
private function expandDescendants(Individual $indi, int $depth, array &$set): void private function collectDescendants(Individual $indi, int $depth, array &$bag): void
{ {
if ($depth <= 0) { if ($depth <= 0) {
return; return;
@@ -140,9 +210,9 @@ final class RelationshipPathFinder
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) { foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->children(Auth::PRIV_HIDE) as $child) { foreach ($family->children(Auth::PRIV_HIDE) as $child) {
if (!isset($set[$child->xref()])) { if (!isset($bag[$child->xref()])) {
$set[$child->xref()] = true; $bag[$child->xref()] = $child;
$this->expandDescendants($child, $depth - 1, $set); $this->collectDescendants($child, $depth - 1, $bag);
} }
} }
} }