> Cache: "rootXref|maxDistance" => {xref => distance} */ private array $distance_cache = []; /** @var array Cache: "fromXref|toXref" -> label */ private array $label_cache = []; /** @var array Cache: languageTag -> module */ private array $language_cache = []; public function __construct( private readonly RelationshipService $relationship_service, private readonly ModuleService $module_service, ) { } /** * Returns the relationship label, or null if no path was found * within the configured depth. */ public function label(Individual $from, Individual $to, int $max_depth = self::DEFAULT_MAX_DEPTH): string|null { $cache_key = $from->xref() . '|' . $to->xref(); if (array_key_exists($cache_key, $this->label_cache)) { return $this->label_cache[$cache_key]; } $path = $this->findPath($from, $to, $max_depth); if ($path === []) { return $this->label_cache[$cache_key] = null; } $language = $this->languageForCurrentLocale(); if ($language === null) { // Every webtrees install has at least the English language // module enabled by default; if not, we can't label. return $this->label_cache[$cache_key] = null; } return $this->label_cache[$cache_key] = $this->relationship_service->nameFromPath($path, $language); } /** * 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. */ 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 xref => smallest distance found */ public function kinDistances(Individual $root, int $max_distance): array { $cache_key = $root->xref() . '|' . $max_distance; if (isset($this->distance_cache[$cache_key])) { return $this->distance_cache[$cache_key]; } // 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); /** @var array $distance */ $distance = []; /** @var array $frontier */ $frontier = []; foreach ($anchors as $xref => $individual) { $distance[$xref] = 0; $frontier[$xref] = $individual; } // Spouses of anchors inherit distance 0 (so own spouse, // step-parents, step-grandparents, ... all qualify). $this->propagateSpouses($frontier, 0, $distance, $frontier); for ($d = 1; $d <= $max_distance && $frontier !== []; $d++) { $next = []; 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; } /** * 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 $sources * @param array $distance * @param array $into */ 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 $bag */ private function collectAncestors(Individual $indi, int $depth, array &$bag): void { if ($depth <= 0) { return; } foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) { foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) { if (!isset($bag[$parent->xref()])) { $bag[$parent->xref()] = $parent; $this->collectAncestors($parent, $depth - 1, $bag); } } } } /** * @param array $bag */ private function collectDescendants(Individual $indi, int $depth, array &$bag): void { if ($depth <= 0) { return; } foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) { foreach ($family->children(Auth::PRIV_HIDE) as $child) { if (!isset($bag[$child->xref()])) { $bag[$child->xref()] = $child; $this->collectDescendants($child, $depth - 1, $bag); } } } } /** * BFS over child- and spouse-families, exactly matching * RelationshipService::getCloseRelationship's traversal but with * an externally configurable max depth. * * @return array */ private function findPath(Individual $from, Individual $to, int $max_depth): array { if ($from === $to || $from->xref() === $to->xref()) { return [$from]; } $visited = [$from->xref() => true]; $paths = [[$from]]; while ($max_depth >= 0) { $max_depth--; foreach ($paths as $i => $path) { $indi = $path[count($path) - 1]; foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) { $result = $this->expandFamily($family, $path, $to, $visited, $paths); if ($result !== null) { return $result; } } foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) { $result = $this->expandFamily($family, $path, $to, $visited, $paths); if ($result !== null) { return $result; } } unset($paths[$i]); } } return []; } /** * @param array $path * @param array $visited * @param array> $paths * * @return array|null Completed path if $to was reached. */ private function expandFamily( Family $family, array $path, Individual $to, array &$visited, array &$paths, ): array|null { $visited[$family->xref()] = true; foreach ($family->spouses(Auth::PRIV_HIDE) as $spouse) { if (isset($visited[$spouse->xref()])) { continue; } $new_path = $path; $new_path[] = $family; $new_path[] = $spouse; if ($spouse->xref() === $to->xref()) { return $new_path; } $paths[] = $new_path; $visited[$spouse->xref()] = true; } foreach ($family->children(Auth::PRIV_HIDE) as $child) { if (isset($visited[$child->xref()])) { continue; } $new_path = $path; $new_path[] = $family; $new_path[] = $child; if ($child->xref() === $to->xref()) { return $new_path; } $paths[] = $new_path; $visited[$child->xref()] = true; } return null; } private function languageForCurrentLocale(): ModuleLanguageInterface|null { $tag = I18N::languageTag(); if (!array_key_exists($tag, $this->language_cache)) { $this->language_cache[$tag] = $this->module_service ->findByInterface(ModuleLanguageInterface::class, true) ->first(fn (ModuleLanguageInterface $module): bool => $module->locale()->languageTag() === $tag); } return $this->language_cache[$tag]; } }