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:
@@ -132,7 +132,7 @@ use Illuminate\Support\Collection;
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label" for="lineal-<?= $id ?>">
|
||||
<?= I18N::translate('Detailed view depth (generations)') ?>
|
||||
<?= I18N::translate('Detailed view distance') ?>
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input class="form-control" type="number" style="max-width: 18rem;"
|
||||
@@ -141,7 +141,7 @@ use Illuminate\Support\Collection;
|
||||
min="<?= Configuration::MIN_LINEAL_DEPTH ?>"
|
||||
max="<?= Configuration::MAX_LINEAL_DEPTH ?>" required>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -125,9 +125,9 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
||||
'Other birthdays' => 'Weitere Geburtstage',
|
||||
'Other anniversaries' => 'Weitere Hochzeitstage',
|
||||
'Other historical events' => 'Weitere historische Ereignisse',
|
||||
'Detailed view depth (generations)' => 'Detailansicht-Tiefe (Generationen)',
|
||||
'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.'
|
||||
=> '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.',
|
||||
'Detailed view distance' => 'Detailansicht-Abstand',
|
||||
'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.'
|
||||
=> '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 wedding anniversary' => '%s Hochzeitstag',
|
||||
'Birthday' => 'Geburtstag',
|
||||
|
||||
@@ -311,13 +311,13 @@ final class NewsletterDispatchService
|
||||
return array_fill_keys(array_keys($featured), true);
|
||||
}
|
||||
|
||||
$depth = Configuration::linealDepth($tree);
|
||||
$lineal_set = $this->relationship_finder->linealKin($self, $depth);
|
||||
$max_distance = Configuration::linealDepth($tree);
|
||||
$distances = $this->relationship_finder->kinDistances($self, $max_distance);
|
||||
|
||||
$detailed = [];
|
||||
|
||||
foreach ($featured as $xref => $_individual) {
|
||||
if (isset($lineal_set[$xref])) {
|
||||
if (isset($distances[$xref]) && $distances[$xref] <= $max_distance) {
|
||||
$detailed[$xref] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ final class RelationshipPathFinder
|
||||
/** Default BFS depth — ~7 generations of ancestry / descent. */
|
||||
public const int DEFAULT_MAX_DEPTH = 14;
|
||||
|
||||
/** @var array<string,array<string,true>> Cache: xref => {ancestor/descendant xref set} */
|
||||
private array $lineal_cache = [];
|
||||
/** @var array<string,array<string,int>> Cache: "rootXref|maxDistance" => {xref => distance} */
|
||||
private array $distance_cache = [];
|
||||
|
||||
/** @var array<string,string|null> Cache: "fromXref|toXref" -> label */
|
||||
private array $label_cache = [];
|
||||
@@ -78,42 +78,112 @@ final class RelationshipPathFinder
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of xrefs that are direct ancestors or direct descendants of
|
||||
* `$root` within `$depth` generations.
|
||||
*
|
||||
* 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>
|
||||
* Hard cap on how far up / down we'll walk when seeding the direct
|
||||
* lineal anchor set. 25 generations is well beyond practical
|
||||
* genealogy and prevents pathological data from running away.
|
||||
*/
|
||||
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) {
|
||||
return [$root->xref() => true];
|
||||
$cache_key = $root->xref() . '|' . $max_distance;
|
||||
|
||||
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])) {
|
||||
return $this->lineal_cache[$cache_key];
|
||||
/** @var array<string,int> $distance */
|
||||
$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);
|
||||
$this->expandDescendants($root, $depth, $set);
|
||||
for ($d = 1; $d <= $max_distance && $frontier !== []; $d++) {
|
||||
$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) {
|
||||
return;
|
||||
@@ -121,18 +191,18 @@ final class RelationshipPathFinder
|
||||
|
||||
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
|
||||
foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
|
||||
if (!isset($set[$parent->xref()])) {
|
||||
$set[$parent->xref()] = true;
|
||||
$this->expandAncestors($parent, $depth - 1, $set);
|
||||
if (!isset($bag[$parent->xref()])) {
|
||||
$bag[$parent->xref()] = $parent;
|
||||
$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) {
|
||||
return;
|
||||
@@ -140,9 +210,9 @@ final class RelationshipPathFinder
|
||||
|
||||
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
|
||||
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
|
||||
if (!isset($set[$child->xref()])) {
|
||||
$set[$child->xref()] = true;
|
||||
$this->expandDescendants($child, $depth - 1, $set);
|
||||
if (!isset($bag[$child->xref()])) {
|
||||
$bag[$child->xref()] = $child;
|
||||
$this->collectDescendants($child, $depth - 1, $bag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user