| File: | blib/lib/HTML/Genealogy/Map.pm |
| Coverage: | 79.8% |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package HTML::Genealogy::Map; | ||||||
| 2 | |||||||
| 3 | 3 3 3 | 173847 3 26 | use strict; | ||||
| 4 | 3 3 3 | 3 1 39 | use warnings; | ||||
| 5 | |||||||
| 6 | 3 3 3 | 424 200 5 | use utf8; | ||||
| 7 | |||||||
| 8 | 3 3 3 | 199 373 6 | use open qw(:std :encoding(UTF-8)); | ||||
| 9 | |||||||
| 10 | 3 3 3 | 6366 8728 6 | use autodie; | ||||
| 11 | 3 3 3 | 6046 1010381 69 | use Date::Cmp; | ||||
| 12 | 3 3 3 | 496 18829 33 | use HTML::GoogleMaps::V3; | ||||
| 13 | 3 3 3 | 625 95947 48 | use HTML::OSM; | ||||
| 14 | 3 3 3 | 8 17 23 | use Object::Configure 0.15; | ||||
| 15 | 3 3 3 | 3 10 37 | use Params::Get 0.13; | ||||
| 16 | 3 3 3 | 4 7 33 | use Params::Validate::Strict 0.16; | ||||
| 17 | 3 3 3 | 3 6 2394 | use Scalar::Util qw(blessed); | ||||
| 18 | |||||||
| 19 - 27 | =head1 NAME HTML::Genealogy::Map - Extract and map genealogical events from a GEDCOM file =head1 VERSION Version 0.06 =cut | ||||||
| 28 | |||||||
| 29 | our $VERSION = '0.06'; | ||||||
| 30 | |||||||
| 31 | # The backends of Geo::Coder::Free that answer from a local database, named as | ||||||
| 32 | # they put themselves in a result's 'database' key. | ||||||
| 33 | my %LOCAL_DATABASES = map { $_ => 1 } qw(OpenAddresses MaxMind Local Geo::Coder::Free::Local); | ||||||
| 34 | |||||||
| 35 - 124 | =head1 DESCRIPTION
This module parses GEDCOM genealogy files and creates an interactive map showing
the locations of births, marriages, and deaths. Events at the same location are
grouped together in a single marker with a scrollable popup.
=head1 SUBROUTINES/METHODS
=head2 onload_render
Render the map.
It takes two mandatory and one optional parameter.
It returns an array of two elements, the items for the C<head> and C<body>.
=over 4
=item B<gedcom>
L<GEDCOM> object to process.
=item B<geocoder>
Geocoder to use.
Its results are read through the coordinates in C<lat> and C<long>/C<lng> -
through C<latitude()>/C<longitude()> when the geocoder returns an object -
so L<Geo::Location::Point> and anything else L<HTML::OSM> accepts will do.
A result's C<database> says which local dataset answered, and an
L<Geo::Coder::Free> result therefore does not attract the courtesy delay that
a remote service gets.
=item B<google_key>
Key to Google's map API.
=item B<debug>
Enable print statements of what's going on
=back
=head1 FEATURES
=over 4
=item * Extracts births, marriages, and deaths with location data
=item * Geocodes locations using multiple fallback providers
=item * Groups events at the same location (within ~0.1m precision)
=item * Color-coded event indicators (green=birth, blue=marriage, red=death)
=item * Sorts events chronologically within each category
=item * Scrollable popups for locations with more than 5 events
=item * Persistent caching of geocoding results
=item * For OpenStreetMap: centers on location with most events
=back
=head3 API SPECIFICATION
=head4 INPUT
{
'gedcom' => { 'type' => 'object', 'can' => 'individuals' },
'geocoder' => { 'type' => 'object', 'can' => 'geocode' },
'debug' => { 'type' => 'boolean', optional => 1 },
'google_key' => { 'type' => 'string', optional => 1, min => 39, max => 39, matches => qr/^AIza[0-9A-Za-z_-]{35}$/ },
'height' => { optional => 1 },
'width' => { optional => 1 }
}
=head4 OUTPUT
Argument error: croak
No matches found: undef
Returns an array of two strings:
{
'type' => 'array',
'min' => 2,
'max' => 2,
'schema' => { 'type' => 'string', min => 10 },
}
=cut | ||||||
| 125 | |||||||
| 126 | sub onload_render | ||||||
| 127 | { | ||||||
| 128 | 16 | 1 | 211983 | my $class = ref($_[0]) ? __PACKAGE__ : shift; | |||
| 129 | |||||||
| 130 | # Configuration | ||||||
| 131 | 16 | 25 | my $params = Params::Validate::Strict::validate_strict({ | ||||
| 132 | args => Params::Get::get_params('gedcom', @_), | ||||||
| 133 | schema => { | ||||||
| 134 | 'gedcom' => { 'type' => 'object', 'can' => 'individuals' }, | ||||||
| 135 | 'geocoder' => { 'type' => 'object', 'can' => 'geocode' }, | ||||||
| 136 | 'debug' => { 'type' => 'boolean', optional => 1 }, | ||||||
| 137 | 'google_key' => { 'type' => 'string', optional => 1, min => 39, max => 39, matches => qr/^AIza[0-9A-Za-z_-]{35}$/ }, | ||||||
| 138 | 'height' => { optional => 1, type => 'string' }, | ||||||
| 139 | 'width' => { optional => 1, type => 'string' }, | ||||||
| 140 | } | ||||||
| 141 | }); | ||||||
| 142 | |||||||
| 143 | 13 | 2265 | $params = Object::Configure::configure($class, $params); | ||||
| 144 | |||||||
| 145 | 13 | 18462 | my $ged = $params->{'gedcom'}; | ||||
| 146 | 13 | 12 | my $debug = $params->{'debug'}; | ||||
| 147 | 13 | 9 | my $google_key = $params->{'google_key'}; | ||||
| 148 | 13 | 10 | my $geocoder = $params->{'geocoder'}; | ||||
| 149 | 13 | 23 | my $height = $params->{'height'} || '400px'; | ||||
| 150 | 13 | 17 | my $width = $params->{'width'} || '600px'; | ||||
| 151 | |||||||
| 152 | # Storage for events | ||||||
| 153 | 13 | 11 | my @events; | ||||
| 154 | |||||||
| 155 | 13 | 10 | print "Parsing GEDCOM file...\n" if($debug); | ||||
| 156 | |||||||
| 157 | # Process all individuals | ||||||
| 158 | 13 | 23 | foreach my $indi ($ged->individuals) { | ||||
| 159 | 12 | 31 | next unless(ref($indi)); | ||||
| 160 | 12 | 15 | my $name = $indi->name || 'Unknown'; | ||||
| 161 | 12 | 30 | $name =~ s/\///g; # Remove GEDCOM name delimiters | ||||
| 162 | 12 | 11 | $name =~ s/'/'/g; # Probably this is enough HTML::Entities | ||||
| 163 | |||||||
| 164 | # Birth events | ||||||
| 165 | 12 | 15 | if (my $birth = $indi->birth) { | ||||
| 166 | 11 | 28 | if (ref($birth) && (my $place = $birth->place)) { | ||||
| 167 | 10 | 25 | push @events, { | ||||
| 168 | type => 'birth', | ||||||
| 169 | name => $name, | ||||||
| 170 | place => $place, | ||||||
| 171 | date => $birth->date || 'Unknown date', | ||||||
| 172 | }; | ||||||
| 173 | } | ||||||
| 174 | } | ||||||
| 175 | |||||||
| 176 | # Death events | ||||||
| 177 | 12 | 33 | if (my $death = $indi->death) { | ||||
| 178 | 1 | 4 | if (ref($death) && (my $place = $death->place)) { | ||||
| 179 | 1 | 4 | push @events, { | ||||
| 180 | type => 'death', | ||||||
| 181 | name => $name, | ||||||
| 182 | place => $place, | ||||||
| 183 | date => $death->date || 'Unknown date', | ||||||
| 184 | }; | ||||||
| 185 | } | ||||||
| 186 | } | ||||||
| 187 | } | ||||||
| 188 | |||||||
| 189 | # Process all families (marriages) | ||||||
| 190 | 13 | 35 | foreach my $fam ($ged->families) { | ||||
| 191 | 1 | 6 | next unless defined($fam) && ref($fam); # Yes, really | ||||
| 192 | |||||||
| 193 | 1 | 2 | my $husband = (ref($fam->husband) && $fam->husband->name) ? $fam->husband->name : 'Unknown'; | ||||
| 194 | 1 | 14 | my $wife = (ref($fam->wife) && $fam->wife->name) ? $fam->wife->name : 'Unknown'; | ||||
| 195 | |||||||
| 196 | 1 | 6 | $husband =~ s/\///g; | ||||
| 197 | 1 | 1 | $wife =~ s/\///g; | ||||
| 198 | |||||||
| 199 | 1 | 2 | if (my $marriage = $fam->marriage) { | ||||
| 200 | 1 | 6 | if (ref($marriage) && (my $place = $marriage->place)) { | ||||
| 201 | 1 | 2 | $husband =~ s/'/'/; | ||||
| 202 | 1 | 1 | $wife =~ s/'/'/; | ||||
| 203 | 1 | 3 | push @events, { | ||||
| 204 | type => 'marriage', | ||||||
| 205 | name => "$husband & $wife", | ||||||
| 206 | place => $place, | ||||||
| 207 | date => $marriage->date || 'Unknown date', | ||||||
| 208 | }; | ||||||
| 209 | } | ||||||
| 210 | } | ||||||
| 211 | } | ||||||
| 212 | |||||||
| 213 | 13 | 31 | print 'Found ', scalar(@events), " events with location data.\n" if($debug); | ||||
| 214 | 13 | 10 | print "Geocoding locations...\n" if($debug); | ||||
| 215 | |||||||
| 216 | # Geocode all events | ||||||
| 217 | 13 | 7 | my @geocoded_events; | ||||
| 218 | my %cache; # TODO allow use of params->{cache} if given | ||||||
| 219 | |||||||
| 220 | 13 | 7 | foreach my $event (@events) { | ||||
| 221 | 12 | 12 | my $place = $event->{place}; | ||||
| 222 | |||||||
| 223 | # Check cache | ||||||
| 224 | 12 | 14 | unless (exists $cache{$place}) { | ||||
| 225 | 11 | 11 | my $location = $geocoder->geocode(location => $place); | ||||
| 226 | 11 | 170 | my ($lat, $long) = _coordinates($location); | ||||
| 227 | |||||||
| 228 | 11 | 55 | if (defined($lat) && defined($long)) { | ||||
| 229 | 10 | 15 | $cache{$place} = { | ||||
| 230 | lat => $lat, | ||||||
| 231 | lon => $long, # The cache's own key, not a key of the result | ||||||
| 232 | }; | ||||||
| 233 | 10 | 7 | print "\tGeocoded: $place\n" if($debug); | ||||
| 234 | 10 | 14 | sleep 1 unless _is_local($location); # Be nice to geocoding service | ||||
| 235 | |||||||
| 236 | } else { | ||||||
| 237 | 1 | 1 | print "\tFailed to geocode: $place\n" if($debug); | ||||
| 238 | 1 | 1 | $cache{$place} = undef; | ||||
| 239 | 1 | 1000123 | sleep 1; # Be nice to geocoding service | ||||
| 240 | } | ||||||
| 241 | } | ||||||
| 242 | |||||||
| 243 | 12 | 105 | if ($cache{$place}) { | ||||
| 244 | push @geocoded_events, { | ||||||
| 245 | %$event, | ||||||
| 246 | lat => $cache{$place}{lat}, | ||||||
| 247 | lon => $cache{$place}{lon}, | ||||||
| 248 | 11 | 60 | }; | ||||
| 249 | } | ||||||
| 250 | } | ||||||
| 251 | |||||||
| 252 | 13 | 24 | print 'Successfully geocoded ', scalar(@geocoded_events), " events.\n" if($debug); | ||||
| 253 | |||||||
| 254 | 13 | 43 | return('', '') if(scalar(@geocoded_events) == 0); # Empty | ||||
| 255 | |||||||
| 256 | 8 | 9 | print "Generating map...\n" if($debug); | ||||
| 257 | |||||||
| 258 | # Group events by location | ||||||
| 259 | 8 | 34 | my %location_groups; | ||||
| 260 | 8 | 8 | foreach my $event (@geocoded_events) { | ||||
| 261 | 11 | 69 | my $key = sprintf('%.6f,%.6f', $event->{lat}, $event->{lon}); | ||||
| 262 | 11 11 | 9 15 | push @{$location_groups{$key}}, $event; | ||||
| 263 | } | ||||||
| 264 | |||||||
| 265 | # Generate map based on available API key | ||||||
| 266 | 8 | 6 | my $map; | ||||
| 267 | 8 | 9 | if ($google_key) { | ||||
| 268 | 0 | 0 | $map = _generate_google_map(\%location_groups, $height, $width, $google_key); | ||||
| 269 | } else { | ||||||
| 270 | 8 | 19 | $map = _generate_osm_map(\%location_groups, $height, $width); | ||||
| 271 | } | ||||||
| 272 | |||||||
| 273 | 8 | 13 | return $map->onload_render(); | ||||
| 274 | } | ||||||
| 275 | |||||||
| 276 | # Purpose: Read the position out of a geocoder result. | ||||||
| 277 | # Entry: $location â a Geo::Location::Point (or anything else with | ||||||
| 278 | # latitude()/longitude(), which is what HTML::OSM accepts), or a | ||||||
| 279 | # plain hashref from another geocoder, or undef. | ||||||
| 280 | # Exit: ($lat, $lon) with either possibly undef; an empty list for a result | ||||||
| 281 | # that carries no position. | ||||||
| 282 | sub _coordinates { | ||||||
| 283 | 11 | 8 | my $location = shift; | ||||
| 284 | |||||||
| 285 | 11 | 11 | return unless defined($location); | ||||
| 286 | |||||||
| 287 | 10 | 26 | if(blessed($location) && $location->can('latitude') && $location->can('longitude')) { | ||||
| 288 | 6 | 6 | return ($location->latitude(), $location->longitude()); | ||||
| 289 | } | ||||||
| 290 | |||||||
| 291 | 4 | 4 | if(ref($location) eq 'HASH') { | ||||
| 292 | # lat/long/lng are the keys Geo::Location::Point itself builds; 'lon' is | ||||||
| 293 | # only a third fallback, for geocoders that spell longitude that way. It | ||||||
| 294 | # is not part of the point interface: only one of Geo::Coder::Free's | ||||||
| 295 | # MaxMind shortcuts ever sets it. | ||||||
| 296 | return ($location->{'lat'}, | ||||||
| 297 | 4 | 12 | $location->{'long'} // $location->{'lng'} // $location->{'lon'}); | ||||
| 298 | } | ||||||
| 299 | |||||||
| 300 | 0 | 0 | return; | ||||
| 301 | } | ||||||
| 302 | |||||||
| 303 | # Purpose: Is this result from a local dataset, so that it needs no courtesy | ||||||
| 304 | # delay before the next lookup? | ||||||
| 305 | # Entry: $location â a geocoder result. | ||||||
| 306 | # Exit: 1 if it came from an offline database, 0 otherwise. | ||||||
| 307 | sub _is_local { | ||||||
| 308 | 10 | 4 | my $location = shift; | ||||
| 309 | |||||||
| 310 | 10 | 8 | return 0 unless ref($location); | ||||
| 311 | 10 | 8 | return 0 if(ref($location) eq 'ARRAY'); # A flat [lat, lon] says nothing about provenance | ||||
| 312 | |||||||
| 313 | # 'database' is set by every Geo::Coder::Free backend and names the one that | ||||||
| 314 | # answered, in one of two vocabularies (%LOCAL_DATABASES holds both). | ||||||
| 315 | # 'geocoder' names the distribution instead: Geo::Coder::Free has set it since | ||||||
| 316 | # 0.45, and a wrapper such as Geo::Coder::List stamps it on its own results, | ||||||
| 317 | # so it is tested second - a result carrying neither key is not guessed to be | ||||||
| 318 | # local. | ||||||
| 319 | 10 | 28 | return 1 if(defined($location->{'database'}) && $LOCAL_DATABASES{$location->{'database'}}); | ||||
| 320 | 5 | 15 | return 1 if(defined($location->{'geocoder'}) && $location->{'geocoder'} =~ /^Geo::Coder::Free/); | ||||
| 321 | |||||||
| 322 | 3 | 3000355 | return 0; | ||||
| 323 | } | ||||||
| 324 | |||||||
| 325 | # Generate HTML for grouped events | ||||||
| 326 | sub _generate_popup_html { | ||||||
| 327 | 10 | 7 | my ($events) = @_; | ||||
| 328 | |||||||
| 329 | 10 | 9 | my $place = $events->[0]{place}; | ||||
| 330 | 10 | 5 | my $event_count = scalar(@$events); | ||||
| 331 | |||||||
| 332 | # Add scrollable container if more than 5 events | ||||||
| 333 | 10 | 10 | my $container_start = ''; | ||||
| 334 | 10 | 6 | my $container_end = ''; | ||||
| 335 | 10 | 13 | if ($event_count > 5) { | ||||
| 336 | 0 | 0 | $container_start = '<div style="max-height: 300px; overflow-y: auto;">'; | ||||
| 337 | 0 | 0 | $container_end = '</div>'; | ||||
| 338 | } | ||||||
| 339 | |||||||
| 340 | 10 | 8 | my $html = "<b>$place</b><br><br>$container_start"; | ||||
| 341 | |||||||
| 342 | # Group by type | ||||||
| 343 | 10 | 6 | my %by_type; | ||||
| 344 | 10 | 8 | foreach my $event (@$events) { | ||||
| 345 | 11 11 | 8 14 | push @{$by_type{$event->{type}}}, $event; | ||||
| 346 | } | ||||||
| 347 | |||||||
| 348 | # Sort function for dates | ||||||
| 349 | my $sort_by_date = sub { | ||||||
| 350 | 1 | 1 | my $date_a = $a->{'date'}; | ||||
| 351 | 1 | 1 | my $date_b = $b->{'date'}; | ||||
| 352 | |||||||
| 353 | # Put unknown dates at the end | ||||||
| 354 | 1 | 4 | return 1 if $date_a =~ /^Unknown/i && $date_b !~ /^Unknown/i; | ||||
| 355 | 1 | 2 | return -1 if $date_b =~ /^Unknown/i && $date_a !~ /^Unknown/i; | ||||
| 356 | 1 | 2 | return 0 if $date_a =~ /^Unknown/i && $date_b =~ /^Unknown/i; | ||||
| 357 | |||||||
| 358 | 1 | 4 | return Date::Cmp::datecmp($date_a, $date_b); | ||||
| 359 | 10 | 15 | }; | ||||
| 360 | |||||||
| 361 | # Add births | ||||||
| 362 | 10 | 12 | if ($by_type{birth}) { | ||||
| 363 | 8 | 6 | $html .= '<b>Births:</b><br>'; | ||||
| 364 | 8 8 | 5 9 | foreach my $event (sort $sort_by_date @{$by_type{birth}}) { | ||||
| 365 | $html .= sprintf( | ||||||
| 366 | '<span style="color: green; font-size: 20px;">●</span> %s (%s)<br>', | ||||||
| 367 | $event->{name}, | ||||||
| 368 | $event->{date} | ||||||
| 369 | 9 | 86 | ); | ||||
| 370 | } | ||||||
| 371 | 8 | 5 | $html .= '<br>'; | ||||
| 372 | } | ||||||
| 373 | |||||||
| 374 | # Add marriages | ||||||
| 375 | 10 | 13 | if ($by_type{marriage}) { | ||||
| 376 | 1 | 1 | $html .= '<b>Marriages:</b><br>'; | ||||
| 377 | 1 1 | 2 1 | foreach my $event (sort $sort_by_date @{$by_type{marriage}}) { | ||||
| 378 | $html .= sprintf( | ||||||
| 379 | '<span style="color: blue; font-size: 20px;">●</span> %s (%s)<br>', | ||||||
| 380 | $event->{name}, | ||||||
| 381 | $event->{date} | ||||||
| 382 | 1 | 2 | ); | ||||
| 383 | } | ||||||
| 384 | 1 | 1 | $html .= '<br>'; | ||||
| 385 | } | ||||||
| 386 | |||||||
| 387 | # Add deaths | ||||||
| 388 | 10 | 7 | if ($by_type{death}) { | ||||
| 389 | 1 | 1 | $html .= '<b>Deaths:</b><br>'; | ||||
| 390 | 1 1 | 2 2 | foreach my $event (sort $sort_by_date @{$by_type{death}}) { | ||||
| 391 | $html .= sprintf( | ||||||
| 392 | '<span style="color: red; font-size: 20px;">●</span> %s (%s)<br>', | ||||||
| 393 | $event->{name}, | ||||||
| 394 | $event->{date} | ||||||
| 395 | 1 | 1 | ); | ||||
| 396 | } | ||||||
| 397 | } | ||||||
| 398 | |||||||
| 399 | 10 | 10 | $html .= $container_end; | ||||
| 400 | |||||||
| 401 | 10 | 41 | return $html; | ||||
| 402 | } | ||||||
| 403 | |||||||
| 404 | # Generate Google Maps | ||||||
| 405 | sub _generate_google_map { | ||||||
| 406 | 0 | 0 | my ($location_groups, $height, $width, $key) = @_; | ||||
| 407 | |||||||
| 408 | 0 | 0 | my $map = HTML::GoogleMaps::V3->new( | ||||
| 409 | key => $key, | ||||||
| 410 | height => $height, | ||||||
| 411 | width => $width | ||||||
| 412 | ); | ||||||
| 413 | |||||||
| 414 | # Add markers for each location | ||||||
| 415 | 0 | 0 | my $first = 1; | ||||
| 416 | 0 | 0 | foreach my $loc_key (keys %$location_groups) { | ||||
| 417 | 0 | 0 | my $events = $location_groups->{$loc_key}; | ||||
| 418 | 0 | 0 | my ($lat, $lon) = split /,/, $loc_key; | ||||
| 419 | |||||||
| 420 | 0 | 0 | my $html = _generate_popup_html($events); | ||||
| 421 | |||||||
| 422 | 0 | 0 | $map->add_marker( | ||||
| 423 | point => [$lat, $lon], | ||||||
| 424 | html => $html, | ||||||
| 425 | ); | ||||||
| 426 | |||||||
| 427 | # Center on first location | ||||||
| 428 | 0 | 0 | if ($first) { | ||||
| 429 | 0 | 0 | $map->center([$lat, $lon]); | ||||
| 430 | 0 | 0 | $map->zoom(4); | ||||
| 431 | 0 | 0 | $first = 0; | ||||
| 432 | } | ||||||
| 433 | } | ||||||
| 434 | |||||||
| 435 | 0 | 0 | return $map; | ||||
| 436 | } | ||||||
| 437 | |||||||
| 438 | # Generate OpenStreetMap using HTML::OSM | ||||||
| 439 | sub _generate_osm_map { | ||||||
| 440 | 8 | 10 | my ($location_groups, $height, $width) = @_; | ||||
| 441 | |||||||
| 442 | # Create HTML::OSM object | ||||||
| 443 | 8 | 35 | my $osm = HTML::OSM->new(zoom => 12, height => $height, width => $width); | ||||
| 444 | |||||||
| 445 | # Add markers for each location | ||||||
| 446 | 8 | 82065 | foreach my $loc_key (keys %$location_groups) { | ||||
| 447 | 10 | 79 | my $events = $location_groups->{$loc_key}; | ||||
| 448 | 10 | 16 | my ($lat, $lon) = split /,/, $loc_key; | ||||
| 449 | |||||||
| 450 | 10 | 14 | my $html = _generate_popup_html($events); | ||||
| 451 | |||||||
| 452 | 10 | 17 | $osm->add_marker( | ||||
| 453 | point => [$lat, $lon], | ||||||
| 454 | html => $html, | ||||||
| 455 | ); | ||||||
| 456 | } | ||||||
| 457 | |||||||
| 458 | # Find location with most events | ||||||
| 459 | 8 | 261 | my ($center_lat, $center_lon) = (0, 0); | ||||
| 460 | 8 | 5 | my $max_events = 0; | ||||
| 461 | 8 | 8 | foreach my $loc_key (keys %$location_groups) { | ||||
| 462 | 10 10 | 4 14 | my $event_count = scalar(@{$location_groups->{$loc_key}}); | ||||
| 463 | 10 | 10 | if ($event_count > $max_events) { | ||||
| 464 | 8 | 6 | $max_events = $event_count; | ||||
| 465 | 8 | 10 | ($center_lat, $center_lon) = split /,/, $loc_key; | ||||
| 466 | } | ||||||
| 467 | } | ||||||
| 468 | |||||||
| 469 | 8 | 18 | $osm->center([$center_lat, $center_lon]); | ||||
| 470 | |||||||
| 471 | 8 | 198 | return $osm; | ||||
| 472 | } | ||||||
| 473 | |||||||
| 474 - 538 | =head1 BUGS =head1 SEE ALSO =over 4 =item * L<Test Dashboard|https://nigelhorne.github.io/HTML-Genealogy-Map/coverage/> =item * L<Configure an Object at Runtime|Object::Configure> =back =head1 REPOSITORY L<https://github.com/nigelhorne/HTML-Genealogy-Map> =head1 SUPPORT This module is provided as-is without any warranty. Please report any bugs or feature requests to C<bug-html-genealogy-map at rt.cpan.org>, or through the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=HTML-Genealogy-Map>. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes. You can find documentation for this module with the perldoc command. perldoc HTML::Genalogy::Map You can also look for information at: =over 4 =item * MetaCPAN L<https://metacpan.org/dist/HTML-Genealogy-Map> =item * RT: CPAN's request tracker L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=HTML-Genealogy-Map> =item * CPAN Testers' Matrix L<http://matrix.cpantesters.org/?dist=HTML-Genealogy-Map> =item * CPAN Testers Dependencies L<http://deps.cpantesters.org/?module=HTML::Genalogy::Map> =back =head1 AUTHOR Nigel Horne, C<< <njh at nigelhorne.com> >> =head1 LICENCE AND COPYRIGHT Copyright 2025-2026 Nigel Horne. Usage is subject to the GPL2 licence terms. If you use it, please let me know. =cut | ||||||
| 539 | |||||||
| 540 | 1; | ||||||