@@ -38,10 +38,14 @@ The objects that you use with this module need to implement three methods:
3838
3939=over 4
4040
41- =item * parent
41+ =item * parents
4242
43- This method should return the object which is the parent of the current
44- person.
43+ This method should return an array reference containing the objects which are
44+ the parents of the current person. The array reference can contain zero, one
45+ or two objects.
46+
47+ If an object does not have a C<parents() > method, then the module will fall
48+ back to using a C<parent() > method that returns a single parent object.
4549
4650=item * id
4751
@@ -58,35 +62,23 @@ the character 'm' or 'f'.
5862=head2 Note
5963
6064THe objects that you use with this class can actually have different names
61- for these methods. C<parent > , C<id > and C<gender > are the default names
62- used by this module, but you can change them by passing the correct names
65+ for these methods. C<parent > , C<parents > , C< id > and C<gender > are the default
66+ names used by this module, but you can change them by passing the correct names
6367to the constructor. For example:
6468
6569 my $rel = Genealogy::Relationship->new(
6670 parent_field_name => 'progenitor',
71+ parents_field_name => 'progenitors',
6772 identifier_field_name => 'person_id',
6873 gender_field_name => 'sex',
6974 );
7075
7176=head2 Limitations
7277
7378This module was born out of a need I had while creating
74- L<https://lineofsuccession.co.uk/> . This leads to a limitation
75- that I hope to remove at a later date.
76-
77- =over 4
78-
79- =item *
80-
81- Each person in the tree is expected to have only one parent. This is, of
82- course, about half of the usual number. It's like that because for the line
83- of succession I'm tracing bloodlines and only one parent is ever going to
84- be significant.
85-
86- I realise that this is a significant limitation and I'll be thinking about
87- how to fix it as soon as possible.
88-
89- =back
79+ L<https://lineofsuccession.co.uk/> . Relationship calculations are based on
80+ finding the most recent common ancestor between two people, and choosing the
81+ path that uses the fewest generations.
9082
9183=head2 Constructor
9284
@@ -123,6 +115,7 @@ use Lingua::EN::Numbers qw[num2en num2en_ordinal];
123115our $VERSION = ' 1.0.2' ;
124116
125117field $parent_field_name :param = ' parent' ;
118+ field $parents_field_name :param = ' parents' ;
126119field $identifier_field_name :param = ' id' ;
127120field $gender_field_name :param = ' gender' ;
128121
@@ -154,7 +147,9 @@ The following methods are defined.
154147=head2 most_recent_common_ancestor
155148
156149Given two person objects, returns the person who is the most recent common
157- ancestor for the given people.
150+ ancestor for the given people. When multiple common ancestors exist at the
151+ same distance, returns the one reachable via the fewest total generations
152+ across both people.
158153
159154=cut
160155
@@ -165,36 +160,103 @@ method most_recent_common_ancestor {
165160 return $person1
166161 if $person1 -> $identifier_field_name eq $person2 -> $identifier_field_name ;
167162
168- my @ancestors1 = ($person1 , $self -> get_ancestors($person1 ));
169- my @ancestors2 = ($person2 , $self -> get_ancestors($person2 ));
163+ my $map1 = $self -> _ancestor_map($person1 );
164+ my $map2 = $self -> _ancestor_map($person2 );
165+
166+ my ($best_person , $best_total );
170167
171- for my $anc1 (@ancestors1 ) {
172- for my $anc2 (@ancestors2 ) {
173- return $anc1
174- if $anc1 -> $identifier_field_name eq $anc2 -> $identifier_field_name ;
168+ for my $id (keys %$map1 ) {
169+ if (exists $map2 -> {$id }) {
170+ my $total = $map1 -> {$id }{distance } + $map2 -> {$id }{distance };
171+ if (!defined $best_total || $total < $best_total ) {
172+ $best_total = $total ;
173+ $best_person = $map1 -> {$id }{person };
174+ }
175175 }
176176 }
177177
178- die " Can't find a common ancestor.\n " ;
178+ die " Can't find a common ancestor.\n " unless defined $best_person ;
179+
180+ return $best_person ;
181+ }
182+
183+ =head2 _get_parents
184+
185+ Internal method. Given a person object, returns a list of that person's
186+ parents. Uses the C<parents_field_name > method if the person object supports
187+ it; otherwise falls back to the configured C<parent_field_name > method.
188+
189+ =cut
190+
191+ method _get_parents {
192+ my ($person ) = @_ ;
193+
194+ if ($person -> can($parents_field_name )) {
195+ return @{ $person -> $parents_field_name () };
196+ }
197+
198+ my $parent = $person -> $parent_field_name ;
199+ return defined $parent ? ($parent ) : ();
200+ }
201+
202+ =head2 _ancestor_map
203+
204+ Internal method. Given a person object, returns a hash reference mapping
205+ each ancestor's identifier to a hash containing C<distance > (number of
206+ generations from the given person) and C<person > (the ancestor object).
207+ The person themself is included at distance zero.
208+
209+ =cut
210+
211+ method _ancestor_map {
212+ my ($person ) = @_ ;
213+
214+ my %map ;
215+ my @queue = ([$person , 0]);
216+
217+ while (@queue ) {
218+ my ($current , $dist ) = @{ shift @queue };
219+ my $id = $current -> $identifier_field_name ;
220+
221+ next if exists $map {$id };
222+
223+ $map {$id } = { distance => $dist , person => $current };
224+
225+ for my $parent ($self -> _get_parents($current )) {
226+ push @queue , [$parent , $dist + 1];
227+ }
228+ }
229+
230+ return \%map ;
179231}
180232
181233=head2 get_ancestors
182234
183235Given a person object, returns a list of person objects, one for each
184- ancestor of the given person.
236+ ancestor of the given person. When a person has two parents, all ancestors
237+ from both parent lines are included (breadth-first order).
185238
186- The first person in the list will be the person's parent and the last person
187- will be their most distant ancestor.
239+ The first entries in the list will be the person's direct parent(s) and the
240+ last person will be their most distant ancestor.
188241
189242=cut
190243
191244method get_ancestors {
192245 my ($person ) = @_ ;
193246
194- my @ancestors = ();
195-
196- while (defined ($person = $person -> $parent_field_name )) {
197- push @ancestors , $person ;
247+ my %visited ;
248+ my @ancestors ;
249+ my @queue = ($person );
250+
251+ while (@queue ) {
252+ my $current = shift @queue ;
253+ for my $parent ($self -> _get_parents($current )) {
254+ my $id = $parent -> $identifier_field_name ;
255+ unless ($visited {$id }++) {
256+ push @ancestors , $parent ;
257+ push @queue , $parent ;
258+ }
259+ }
198260 }
199261
200262 return @ancestors ;
@@ -328,6 +390,9 @@ the number of generations between the first person and their most recent
328390common ancestor. The second integer is the number of generations between
329391the second person and their most recent common ancestor.
330392
393+ When a person has two parents, the shortest path to the common ancestor
394+ is used.
395+
331396=cut
332397
333398method get_relationship_coords {
@@ -337,18 +402,27 @@ method get_relationship_coords {
337402 return (0, 0)
338403 if $person1 -> $identifier_field_name eq $person2 -> $identifier_field_name ;
339404
340- my @ancestors1 = ($person1 , $self -> get_ancestors($person1 ));
341- my @ancestors2 = ($person2 , $self -> get_ancestors($person2 ));
342-
343- for my $i (0 .. $#ancestors1 ) {
344- for my $j (0 .. $#ancestors2 ) {
345- return ($i , $j )
346- if $ancestors1 [$i ]-> $identifier_field_name
347- eq $ancestors2 [$j ]-> $identifier_field_name ;
405+ my $map1 = $self -> _ancestor_map($person1 );
406+ my $map2 = $self -> _ancestor_map($person2 );
407+
408+ my ($best_i , $best_j , $best_total );
409+
410+ for my $id (keys %$map1 ) {
411+ if (exists $map2 -> {$id }) {
412+ my $i = $map1 -> {$id }{distance };
413+ my $j = $map2 -> {$id }{distance };
414+ my $total = $i + $j ;
415+ if (!defined $best_total || $total < $best_total ) {
416+ $best_total = $total ;
417+ $best_i = $i ;
418+ $best_j = $j ;
419+ }
348420 }
349421 }
350422
351- die " Can't work out the relationship.\n " ;
423+ die " Can't work out the relationship.\n " unless defined $best_total ;
424+
425+ return ($best_i , $best_j );
352426}
353427
354428=head2 get_relationship_ancestors
@@ -357,10 +431,13 @@ Given two people, returns lists of people linking those two people
357431to their most recent common ancestor.
358432
359433The return value is a reference to an array containing two array
360- references. The first references array contains the person1 and
361- all their ancestors up to an including the most recent common
434+ references. The first referenced array contains the person1 and
435+ all their ancestors up to and including the most recent common
362436ancestor. The second list does the same for person2.
363437
438+ When a person has two parents, the shortest path to the common ancestor
439+ is used.
440+
364441=cut
365442
366443method get_relationship_ancestors {
@@ -369,19 +446,48 @@ method get_relationship_ancestors {
369446 my $mrca = $self -> most_recent_common_ancestor($person1 , $person2 )
370447 or die " There is no most recent common ancestor\n " ;
371448
372- my (@ancestors1 , @ancestors2 );
449+ return [
450+ $self -> _path_to_ancestor($person1 , $mrca ),
451+ $self -> _path_to_ancestor($person2 , $mrca ),
452+ ];
453+ }
454+
455+ =head2 _path_to_ancestor
373456
374- for ($person1 , $self -> get_ancestors($person1 )) {
375- push @ancestors1 , $_ ;
376- last if $_ -> $identifier_field_name eq $mrca -> $identifier_field_name ;
377- }
457+ Internal method. Given a person object and a target ancestor object, returns
458+ an array reference containing the shortest path from the person to the
459+ ancestor (inclusive of both endpoints). Uses breadth-first search so that
460+ the shortest path is always found, even when a person has two parents.
461+
462+ =cut
463+
464+ method _path_to_ancestor {
465+ my ($person , $target ) = @_ ;
378466
379- for ($person2 , $self -> get_ancestors($person2 )) {
380- push @ancestors2 , $_ ;
381- last if $_ -> $identifier_field_name eq $mrca -> $identifier_field_name ;
467+ my $target_id = $target -> $identifier_field_name ;
468+ my $person_id = $person -> $identifier_field_name ;
469+
470+ return [$person ] if $person_id eq $target_id ;
471+
472+ # BFS to find the shortest path
473+ my @queue = ([$person ]);
474+ my %visited = ($person_id => 1);
475+
476+ while (@queue ) {
477+ my $path = shift @queue ;
478+ my $current = $path -> [-1];
479+
480+ for my $parent ($self -> _get_parents($current )) {
481+ my $parent_id = $parent -> $identifier_field_name ;
482+ next if $visited {$parent_id }++;
483+
484+ my $new_path = [@$path , $parent ];
485+ return $new_path if $parent_id eq $target_id ;
486+ push @queue , $new_path ;
487+ }
382488 }
383489
384- return [ \ @ancestors1 , \ @ancestors2 ] ;
490+ die " No path found to ancestor \n " ;
385491}
386492
387493=head1 AUTHOR
0 commit comments