File Coverage

blib/lib/Geo/Coordinates/OSGB/Grid.pm
Criterion Covered Total %
statement 175 188 93.0
branch 75 96 78.1
condition 18 24 75.0
subroutine 25 25 100.0
pod 7 7 100.0
total 300 340 88.2


line stmt bran cond sub pod time code
1             package Geo::Coordinates::OSGB::Grid;
2              
3 7     7   72019 use Geo::Coordinates::OSGB::Maps qw{%maps %name_for_map_series};
  7         699  
  7         1561  
4              
5 7     7   86 use base qw(Exporter);
  7         16  
  7         940  
6 7     7   52 use strict;
  7         22  
  7         183  
7 7     7   44 use warnings;
  7         21  
  7         248  
8 7     7   39 use Carp;
  7         15  
  7         504  
9 7     7   196 use 5.010; # At least Perl 5.10 please, be sure to change POD below if you update
  7         30  
10              
11             our $VERSION = '2.20';
12              
13             our %EXPORT_TAGS = (all => [qw(
14             parse_grid
15             format_grid
16              
17             parse_trad_grid
18             parse_GPS_grid
19             parse_landranger_grid
20             parse_map_grid
21              
22             format_grid_trad
23             format_grid_GPS
24             format_grid_landranger
25             format_grid_map
26              
27             random_grid
28             )]);
29              
30             our @EXPORT_OK = ( @{ $EXPORT_TAGS{all} } );
31              
32 7     7   42 use constant GRID_SQ_LETTERS => 'VWXYZQRSTULMNOPFGHJKABCDE';
  7         15  
  7         577  
33 7     7   43 use constant GRID_SIZE => sqrt length GRID_SQ_LETTERS;
  7         19  
  7         312  
34 7     7   38 use constant MINOR_GRID_SQ_SIZE => 100_000;
  7         15  
  7         315  
35 7     7   40 use constant MAJOR_GRID_SQ_SIZE => GRID_SIZE * MINOR_GRID_SQ_SIZE;
  7         16  
  7         298  
36 7     7   33 use constant MAJOR_GRID_SQ_EASTING_OFFSET => 2 * MAJOR_GRID_SQ_SIZE;
  7         14  
  7         318  
37 7     7   44 use constant MAJOR_GRID_SQ_NORTHING_OFFSET => 1 * MAJOR_GRID_SQ_SIZE;
  7         17  
  7         368  
38 7     7   40 use constant MAX_GRID_SIZE => MINOR_GRID_SQ_SIZE * length GRID_SQ_LETTERS;
  7         15  
  7         14541  
39              
40             # Produce a random GR
41             # A simple approach would pick 0 < E < 700000 and 0 < N < 1250000 but that way
42             # many GRs produced would be in the sea, so pick a random map, and then find a
43             # random GR within its bbox and finally check that the resulting pair is
44             # and actually on the map.
45              
46             sub random_grid {
47 2     2 1 1204 my @preferred_sheets = @_;
48             # assume plain sheet numbers are Landranger
49 2         7 for my $s (@preferred_sheets) {
50 8 100       43 if ($s =~ m{\A\d+\Z}xsmio) {
51 7         28 $s =~ s/\A/A:/xsmio
52             }
53             }
54 2         4 my @sheets;
55 2 50       7 if (@preferred_sheets) {
56 2         7 @sheets = grep { exists $maps{$_} } @preferred_sheets;
  8         27  
57             }
58             else {
59 0         0 @sheets = keys %maps;
60             }
61 2         7 my ($map, $lle, $lln, $ure, $urn);
62 2         0 my ($easting, $northing);
63 2         5 while (1) {
64 2         9 $map = $maps{$sheets[int rand @sheets]};
65 2         5 ($lle, $lln) = @{$map->{bbox}->[0]};
  2         9  
66 2         5 ($ure, $urn) = @{$map->{bbox}->[1]};
  2         7  
67 2         27 $easting = sprintf '%.3f', $lle + rand ($ure-$lle);
68 2         12 $northing = sprintf '%.3f', $lln + rand ($urn-$lln);
69             # check we are actually on the chosen map
70 2 50       7 last if _winding_number($easting, $northing, $map->{polygon});
71             }
72 2         11 return ($easting, $northing);
73             }
74              
75             sub format_grid {
76 213     213 1 30397 my ($easting, $northing, $options) = @_;
77              
78 213 100       689 my $form = exists $options->{form} ? uc $options->{form} : 'SS EEE NNN';
79 213 100       514 my $with_maps = exists $options->{maps} ? $options->{maps} : 0;
80 213 100       1259 my $map_keys = exists $options->{series} ? uc $options->{series} : join '', sort keys %name_for_map_series;
81              
82 213         671 my $sq = _grid_to_sq($easting,$northing);
83 213 50       510 if ( !$sq ) {
84 0         0 croak "Too far off the grid: $easting $northing\n";
85             }
86              
87 213         474 my ($e,$n) = map { int } map { $_ % MINOR_GRID_SQ_SIZE } ($easting, $northing);
  426         787  
  426         938  
88              
89 213         435 my @sheets = ();
90 213 100       450 if ( $with_maps ) {
91 59         271 while (my ($k,$m) = each %maps) {
92 66847 100       136651 next if index($map_keys, substr($k,0,1)) == -1;
93 61142 100 100     309631 if ($m->{bbox}->[0][0] <= $easting && $easting < $m->{bbox}->[1][0]
      100        
      100        
94             && $m->{bbox}->[0][1] <= $northing && $northing < $m->{bbox}->[1][1]) {
95 208         635 my $w = _winding_number($easting, $northing, $m->{polygon});
96 208 100       460 if ($w != 0) {
97 199         781 push @sheets, $k;
98             }
99             }
100             }
101 59         403 @sheets = sort @sheets;
102             }
103              
104             # special cases
105 213 100       736 if ( $form eq 'TRAD' ) {
    100          
    100          
106 2         7 $form = 'SS EEE NNN';
107             }
108             elsif ( $form eq 'GPS' ) {
109 48         162 $form = 'SS EEEEE NNNNN';
110             }
111             elsif ( $form eq 'SS' ) {
112 1         5 return $sq;
113             }
114              
115 212 50       2346 if ( my ($space_a, $e_spec, $space_b, $n_spec) = $form =~ m{ \A S{1,2}(\s*)(E{1,5})(\s*)(N{1,5}) \Z }iosxm ) {
116 212         440 my $e_len = length $e_spec;
117 212         332 my $n_len = length $n_spec;
118 212         666 $e = int($e / 10**(5 - $e_len));
119 212         423 $n = int($n / 10**(5 - $n_len));
120              
121 212 100       456 if ( wantarray ) {
122 49         516 return ($sq, $e, $n, @sheets)
123             }
124              
125 163         694 my $gr = sprintf '%s%s%0.*d%s%0.*d', $sq, $space_a, $e_len, $e, $space_b, $n_len, $n;
126 163         955 $gr =~ s/\s+/ /g;
127              
128 163 100       386 if ( $with_maps ) {
129 12 50       39 if ( @sheets ) {
130 12         204 return sprintf '%s on %s', $gr, join ', ', @sheets;
131             }
132             else {
133 0         0 return sprintf '%s is not on any maps in series %s', $gr, $map_keys;
134             }
135             }
136             else {
137 151         750 return $gr;
138             }
139             }
140              
141 0         0 croak "Format $form was not recognized";
142             }
143              
144             sub format_grid_trad {
145 7     7 1 447 my ($easting, $northing) = @_;
146 7         24 return format_grid($easting, $northing, { form => 'SS EEE NNN' })
147             }
148              
149             sub format_grid_GPS {
150 10     10 1 504 my ($easting, $northing) = @_;
151 10         46 return format_grid($easting, $northing, { form => 'SS EEEEE NNNNN' })
152             }
153              
154             sub format_grid_map {
155 12     12 1 37 my ($easting, $northing, $options) = @_;
156 12 100       33 if ( defined $options ) {
157 2         7 $options->{maps} = 1
158             }
159             else {
160 10         28 $options = { maps => 1 }
161             }
162 12         41 return format_grid($easting, $northing, $options)
163             }
164              
165             sub format_grid_landranger {
166 4     4 1 386 my ($easting,$northing) = @_;
167              
168 4         29 my ($sq, $e, $n, @sheets) = format_grid($easting, $northing, { form => 'SS EEE NNN', maps => 1, series => 'A' });
169              
170 4         20 for my $s (@sheets) {
171 6         28 $s =~ s/\AA://xsm;
172             }
173              
174 4 100       39 return ($sq, $e, $n, @sheets) if wantarray;
175              
176 1 50       19 if (!@sheets ) { return sprintf '%s %03d %03d is not on any Landranger sheet', $sq, $e, $n }
  0         0  
177 1 50       8 if ( @sheets==1 ) { return sprintf '%s %03d %03d on Landranger sheet %s' , $sq, $e, $n, $sheets[0] }
  1         15  
178 0 0       0 if ( @sheets==2 ) { return sprintf '%s %03d %03d on Landranger sheets %s and %s', $sq, $e, $n, @sheets }
  0         0  
179              
180 0         0 my $phrase = join ', ', @sheets[0..($#sheets-1)], "and $sheets[-1]";
181 0         0 return sprintf '%s %03d %03d on Landranger sheets %s', $sq, $e, $n, $phrase;
182              
183             }
184              
185             sub _grid_to_sq {
186 213     213   450 my ($e, $n) = @_;
187              
188 213         453 $e += MAJOR_GRID_SQ_EASTING_OFFSET;
189 213         332 $n += MAJOR_GRID_SQ_NORTHING_OFFSET;
190 213 50 33     1451 return if !(0 <= $e && $e < MAX_GRID_SIZE && 0 <= $n && $n < MAX_GRID_SIZE);
      33        
      33        
191              
192 213         625 my $major_index = int $e / MAJOR_GRID_SQ_SIZE + GRID_SIZE * int $n / MAJOR_GRID_SQ_SIZE;
193 213         393 $e = $e % MAJOR_GRID_SQ_SIZE;
194 213         293 $n = $n % MAJOR_GRID_SQ_SIZE;
195 213         450 my $minor_index = int $e / MINOR_GRID_SQ_SIZE + GRID_SIZE * int $n / MINOR_GRID_SQ_SIZE;
196             return
197 213         730 substr(GRID_SQ_LETTERS, $major_index, 1) .
198             substr(GRID_SQ_LETTERS, $minor_index, 1);
199             }
200              
201             sub _get_grid_square_offsets {
202 117     117   256 my $s = shift;
203 117 50       377 return if length $s < 2;
204              
205 117         406 my $a = index GRID_SQ_LETTERS, uc substr $s, 0, 1;
206 117 100       310 return if 0 > $a;
207              
208 109         249 my $b = index GRID_SQ_LETTERS, uc substr $s, 1, 1;
209 109 100       387 return if 0 > $b;
210              
211 55         201 my ($X, $Y) = ($a % GRID_SIZE, int $a / GRID_SIZE);
212 55         152 my ($x, $y) = ($b % GRID_SIZE, int $b / GRID_SIZE);
213              
214             return (
215 55         238 MAJOR_GRID_SQ_SIZE * $X - MAJOR_GRID_SQ_EASTING_OFFSET + MINOR_GRID_SQ_SIZE * $x,
216             MAJOR_GRID_SQ_SIZE * $Y - MAJOR_GRID_SQ_NORTHING_OFFSET + MINOR_GRID_SQ_SIZE * $y
217             );
218             }
219              
220             sub _get_eastnorthings {
221 112     112   292 my $s = shift;
222 112         220 my $numbers = $s;
223 112         321 $numbers =~ tr/0-9//cd; # avoid using "r" here as it requires perl >= 5.14
224 112         231 my $len = length $numbers;
225 112 50       295 croak 'No easting or northing found' if $len == 0;
226 112 50       331 croak "Easting and northing have different lengths in $s" if $len % 2;
227 112 50       277 croak "Too many digits in $s" if $len > 10;
228              
229             # this trick lets us pad with zeros on the right
230 112         684 my $e = reverse sprintf '%05d', scalar reverse substr $numbers, 0, $len/2;
231 112         773 my $n = reverse sprintf '%05d', scalar reverse substr $numbers, $len/2;
232 224         391 return ($e, $n)
233             }
234              
235             sub parse_grid {
236              
237 117 100   117 1 6379 my $options = 'HASH' eq ref $_[-1] ? pop @_ : { };
238              
239 117 100       363 my $figs = exists $options->{figs} ? $options->{figs} : 3;
240              
241 117         224 my @out;
242              
243 117 100       754 my $s = @_ < 3 ? "@_" : sprintf '%s %0.*d %0.*d', $_[0], $figs, $_[1], $figs, $_[2];
244              
245             # normal case : TQ 123 456 etc
246 117 100       382 if ( my ($E, $N) = _get_grid_square_offsets($s) ) {
247 55 100       238 my ($e, $n) = length $s > 2 ? _get_eastnorthings(substr $s, 2) : (0,0);
248 55         229 @out = ($E+$e, $N+$n);
249 55 100       521 return wantarray ? @out : "@out";
250             }
251              
252             # sheet id instead of grid sq
253 62         405 my $sheet_ref_pattern = qr{\A([A-Z]:)?([0-9NEWSOL/]+?)(\.[a-z]+)?(?:[ -/.]([ 0-9]+))?\Z};
254 62         698 my ($prefix, $sheet, $suffix, $numbers) = $s =~ m/$sheet_ref_pattern/sioxm;
255              
256 62 50       206 if (defined $sheet) {
257              
258 62 100       180 if ( ! defined $prefix ) {
259 8         16 $prefix = 'A:';
260             }
261 62 100       175 if ( ! defined $suffix ) {
262 61         115 $suffix = q{};
263             }
264 62         151 $sheet = $prefix . $sheet . $suffix;
265              
266 62 50       224 if (exists $maps{$sheet}) {
267 62         137 my ($E, $N) = @{$maps{$sheet}->{bbox}[0]}; # NB we need the bbox corner so that it is left and below all points on the map
  62         225  
268              
269 62 100       158 if (defined $numbers) {
270 59         162 my ($e, $n) = _get_eastnorthings($numbers);
271 59         183 $E = $E + ($e-$E) % MINOR_GRID_SQ_SIZE;
272 59         142 $N = $N + ($n-$N) % MINOR_GRID_SQ_SIZE;
273              
274 59         189 my $w = _winding_number($E, $N, $maps{$sheet}->{polygon});
275 59 50       165 if ($w == 0) {
276 0         0 croak sprintf 'Grid reference %s = (%d, %d) is not on sheet %s', scalar format_grid($E,$N), $e, $n, $sheet;
277             }
278             }
279 62 100       559 return wantarray ? ($E, $N) : "$E $N";
280             }
281             }
282              
283             # just a pair of numbers
284 0 0       0 if ( @out = $s =~ m{\A (\d+(?:\.\d+)?) \s+ (\d+(?:\.\d+)?) \Z}xsmio ) { # eee nnn
285 0 0       0 return wantarray ? @out : "@out";
286             }
287              
288 0         0 croak "Failed to parse a grid reference from $s";
289             }
290              
291             *parse_trad_grid = \&parse_grid;
292             *parse_GPS_grid = \&parse_grid;
293             *parse_landranger_grid = \&parse_grid;
294             *parse_map_grid = \&parse_grid;
295              
296             # is $pt left of $a--$b?
297             sub _is_left {
298 538     538   1017 my ($x, $y, $a, $b) = @_;
299 538         2228 return ( ($b->[0] - $a->[0]) * ($y - $a->[1]) - ($x - $a->[0]) * ($b->[1] - $a->[1]) );
300             }
301              
302             # adapted from http://geomalgorithms.com/a03-_inclusion.html
303             sub _winding_number {
304 269     269   555 my ($x, $y, $poly) = @_;
305 269         431 my $w = 0;
306 269         712 for (my $i=0; $i < $#$poly; $i++ ) {
307 4186 100       6633 if ( $poly->[$i][1] <= $y ) {
308 1829 100 100     5089 if ($poly->[$i+1][1] > $y && _is_left($x, $y, $poly->[$i], $poly->[$i+1]) > 0 ) {
309 266         1194 $w++;
310             }
311             }
312             else {
313 2357 100 100     5806 if ($poly->[$i+1][1] <= $y && _is_left($x, $y, $poly->[$i], $poly->[$i+1]) < 0 ) {
314 6         17 $w--;
315             }
316             }
317             }
318 269         518 return $w;
319             }
320              
321             1;
322              
323             __END__