Passed
Pull Request — master (#103)
by Leszek
06:37
created

LatLongFormatter::formatCoordinate()   C

Complexity

Conditions 12
Paths 17

Size

Total Lines 29
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 12

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 29
ccs 15
cts 15
cp 1
rs 5.1612
cc 12
eloc 17
nc 17
nop 2
crap 12

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace DataValues\Geo\Formatters;
4
5
use DataValues\Geo\Values\LatLongValue;
6
use InvalidArgumentException;
7
use ValueFormatters\FormatterOptions;
8
use ValueFormatters\ValueFormatterBase;
9
10
/**
11
 * Geographical coordinates formatter.
12
 * Formats LatLongValue objects.
13
 *
14
 * Supports the following notations:
15
 * - Degree minute second
16
 * - Decimal degrees
17
 * - Decimal minutes
18
 * - Float
19
 *
20
 * Some code in this class has been borrowed from the
21
 * MapsCoordinateParser class of the Maps extension for MediaWiki.
22
 *
23
 * @since 0.1, renamed in 2.0
24
 *
25
 * @license GPL-2.0+
26
 * @author Jeroen De Dauw < [email protected] >
27
 * @author Addshore
28
 * @author Thiemo Mättig
29
 */
30
class LatLongFormatter extends ValueFormatterBase {
31
32
	/**
33
	 * Output formats for use with the self::OPT_FORMAT option.
34
	 */
35
	const TYPE_FLOAT = 'float';
36
	const TYPE_DMS = 'dms';
37
	const TYPE_DM = 'dm';
38
	const TYPE_DD = 'dd';
39
40
	/**
41
	 * The symbols representing the different directions for usage in directional notation.
42
	 * @since 0.1
43
	 */
44
	const OPT_NORTH_SYMBOL = 'north';
45
	const OPT_EAST_SYMBOL = 'east';
46
	const OPT_SOUTH_SYMBOL = 'south';
47
	const OPT_WEST_SYMBOL = 'west';
48
49
	/**
50
	 * The symbols representing degrees, minutes and seconds.
51
	 * @since 0.1
52
	 */
53
	const OPT_DEGREE_SYMBOL = 'degree';
54
	const OPT_MINUTE_SYMBOL = 'minute';
55
	const OPT_SECOND_SYMBOL = 'second';
56
57
	/**
58
	 * Flags for use with the self::OPT_SPACING_LEVEL option.
59
	 */
60
	const OPT_SPACE_LATLONG = 'latlong';
61
	const OPT_SPACE_DIRECTION = 'direction';
62
	const OPT_SPACE_COORDPARTS = 'coordparts';
63
64
	/**
65
	 * Option specifying the output format (also referred to as output type). Must be one of the
66
	 * self::TYPE_… constants.
67
	 */
68
	const OPT_FORMAT = 'geoformat';
69
70
	/**
71
	 * Boolean option specifying if negative coordinates should have minus signs, e.g. "-1°, -2°"
72
	 * (false) or cardinal directions, e.g. "1° S, 2° W" (true). Default is false.
73
	 */
74
	const OPT_DIRECTIONAL = 'directional';
75
76
	/**
77
	 * Option for the separator character between latitude and longitude. Defaults to a comma.
78
	 */
79
	const OPT_SEPARATOR_SYMBOL = 'separator';
80
81
	/**
82
	 * Option specifying the amount and position of space characters in the output. Must be an array
83
	 * containing zero or more of the self::OPT_SPACE_… flags.
84
	 */
85
	const OPT_SPACING_LEVEL = 'spacing';
86
87
	/**
88
	 * Option specifying the precision in fractional degrees. Must be a number or numeric string.
89
	 */
90
	const OPT_PRECISION = 'precision';
91
92
	public function __construct( FormatterOptions $options = null ) {
93
		parent::__construct( $options );
94
95
		$this->defaultOption( self::OPT_NORTH_SYMBOL, 'N' );
96
		$this->defaultOption( self::OPT_EAST_SYMBOL, 'E' );
97 158
		$this->defaultOption( self::OPT_SOUTH_SYMBOL, 'S' );
98 158
		$this->defaultOption( self::OPT_WEST_SYMBOL, 'W' );
99
100 158
		$this->defaultOption( self::OPT_DEGREE_SYMBOL, '°' );
101 158
		$this->defaultOption( self::OPT_MINUTE_SYMBOL, "'" );
102 158
		$this->defaultOption( self::OPT_SECOND_SYMBOL, '"' );
103 158
104
		$this->defaultOption( self::OPT_FORMAT, self::TYPE_FLOAT );
105 158
		$this->defaultOption( self::OPT_DIRECTIONAL, false );
106 158
107 158
		$this->defaultOption( self::OPT_SEPARATOR_SYMBOL, ',' );
108
		$this->defaultOption( self::OPT_SPACING_LEVEL, array(
109 158
			self::OPT_SPACE_LATLONG,
110 158
			self::OPT_SPACE_DIRECTION,
111
			self::OPT_SPACE_COORDPARTS,
112 158
		) );
113 158
		$this->defaultOption( self::OPT_PRECISION, 0 );
114 158
	}
115 158
116 158
	/**
117
	 * @see ValueFormatter::format
118 158
	 *
119 158
	 * Calls formatLatLongValue() using OPT_PRECISION for the $precision parameter.
120
	 *
121
	 * @param LatLongValue $value
122
	 *
123
	 * @return string Plain text
124
	 * @throws InvalidArgumentException
125
	 */
126
	public function format( $value ) {
127
		if ( !( $value instanceof LatLongValue ) ) {
128
			throw new InvalidArgumentException( 'Data value type mismatch. Expected a LatLongValue.' );
129
		}
130
131 152
		$precision = $this->options->getOption( self::OPT_PRECISION );
132 152
133 1
		return $this->formatLatLongValue( $value, $precision );
134
	}
135
136 151
	/**
137
	 * Formats a LatLongValue with the desired precision.
138 151
	 *
139
	 * @since 0.5
140
	 *
141
	 * @param LatLongValue $value
142
	 * @param float|int $precision The desired precision, given as fractional degrees.
143
	 *
144
	 * @return string Plain text
145
	 * @throws InvalidArgumentException
146
	 */
147
	public function formatLatLongValue( LatLongValue $value, $precision ) {
148
		if ( $precision <= 0 || !is_finite( $precision ) ) {
149
			$precision = 1 / 3600;
150
		}
151
152 157
		$formatted = implode(
153 157
			$this->getOption( self::OPT_SEPARATOR_SYMBOL ) . $this->getSpacing( self::OPT_SPACE_LATLONG ),
154 13
			array(
155
				$this->formatLatitude( $value->getLatitude(), $precision ),
156
				$this->formatLongitude( $value->getLongitude(), $precision )
157 157
			)
158 157
		);
159
160 157
		return $formatted;
161 156
	}
162
163
	/**
164
	 * @param string $spacingLevel One of the self::OPT_SPACE_… constants
165 156
	 *
166
	 * @return string
167
	 */
168
	private function getSpacing( $spacingLevel ) {
169
		if ( in_array( $spacingLevel, $this->getOption( self::OPT_SPACING_LEVEL ) ) ) {
170
			return ' ';
171
		}
172
		return '';
173 157
	}
174 157
175 157
	/**
176
	 * @param float $latitude
177 2
	 * @param float|int $precision
178
	 *
179
	 * @return string
180
	 */
181
	private function formatLatitude( $latitude, $precision ) {
182
		return $this->makeDirectionalIfNeeded(
183
			$this->formatCoordinate( $latitude, $precision ),
184
			$this->options->getOption( self::OPT_NORTH_SYMBOL ),
185
			$this->options->getOption( self::OPT_SOUTH_SYMBOL )
186 157
		);
187 157
	}
188 157
189 156
	/**
190 156
	 * @param float $longitude
191
	 * @param float|int $precision
192
	 *
193
	 * @return string
194
	 */
195
	private function formatLongitude( $longitude, $precision ) {
196
		return $this->makeDirectionalIfNeeded(
197
			$this->formatCoordinate( $longitude, $precision ),
198
			$this->options->getOption( self::OPT_EAST_SYMBOL ),
199
			$this->options->getOption( self::OPT_WEST_SYMBOL )
200 156
		);
201 156
	}
202 156
203 156
	/**
204 156
	 * @param string $coordinate
205
	 * @param string $positiveSymbol
206
	 * @param string $negativeSymbol
207
	 *
208
	 * @return string
209
	 */
210
	private function makeDirectionalIfNeeded( $coordinate, $positiveSymbol, $negativeSymbol ) {
211
		if ( $this->options->getOption( self::OPT_DIRECTIONAL ) ) {
212
			return $this->makeDirectional( $coordinate, $positiveSymbol, $negativeSymbol );
213
		}
214
215 156
		return $coordinate;
216 156
	}
217 4
218
	/**
219
	 * @param string $coordinate
220 152
	 * @param string $positiveSymbol
221
	 * @param string $negativeSymbol
222
	 *
223
	 * @return string
224
	 */
225
	private function makeDirectional( $coordinate, $positiveSymbol, $negativeSymbol ) {
226
		$isNegative = substr( $coordinate, 0, 1 ) === '-';
227
228
		if ( $isNegative ) {
229
			$coordinate = substr( $coordinate, 1 );
230 4
		}
231 4
232
		$symbol = $isNegative ? $negativeSymbol : $positiveSymbol;
233 4
234 4
		return $coordinate . $this->getSpacing( self::OPT_SPACE_DIRECTION ) . $symbol;
235
	}
236
237 4
	/**
238
	 * @param float $degrees
239 4
	 * @param float|int $precision
240
	 *
241
	 * @return string
242
	 */
243
	private function formatCoordinate( $degrees, $precision ) {
244
		// Remove insignificant detail
245
		$degrees = $this->roundDegrees( $degrees, $precision );
246
		$format = $this->getOption( self::OPT_FORMAT );
247
248 157
		if ( $format === self::TYPE_FLOAT ) {
249
			return $this->getInFloatFormat( $degrees );
250 157
		}
251 157
252
		if ( $format !== self::TYPE_DD ) {
253 157
			if ( $precision >= 1 - 1 / 60 && $precision < 1 ) {
254 38
				$precision = 1;
255
			} elseif ( $precision >= 1 / 60 - 1 / 3600 && $precision < 1 / 60 ) {
256
				$precision = 1 / 60;
257 119
			}
258 95
		}
259 4
260 91
		if ( $format === self::TYPE_DD || $precision >= 1 ) {
261 2
			return $this->getInDecimalDegreeFormat( $degrees, $precision );
262
		}
263
		if ( $format === self::TYPE_DM || $precision >= 1 / 60 ) {
264
			return $this->getInDecimalMinuteFormat( $degrees, $precision );
265 119
		}
266 42
		if ( $format === self::TYPE_DMS ) {
267
			return $this->getInDegreeMinuteSecondFormat( $degrees, $precision );
268 77
		}
269 42
270
		throw new InvalidArgumentException( 'Invalid coordinate format specified in the options' );
271 35
	}
272 34
273
	/**
274
	 * Round degrees according to OPT_PRECISION
275 1
	 *
276
	 * @param float $degrees
277
	 * @param float|int $precision
278
	 *
279
	 * @return float
280
	 */
281
	private function roundDegrees( $degrees, $precision ) {
282
		$sign = $degrees > 0 ? 1 : -1;
283
		$reduced = round( abs( $degrees ) / $precision );
284
		$expanded = $reduced * $precision;
285
286 157
		return $sign * $expanded;
287 157
	}
288 157
289 157
	/**
290
	 * @param float $floatDegrees
291 157
	 *
292
	 * @return string
293
	 */
294
	private function getInFloatFormat( $floatDegrees ) {
295
		$stringDegrees = (string)$floatDegrees;
296
297
		// Floats are fun...
298
		if ( $stringDegrees === '-0' ) {
299 38
			$stringDegrees = '0';
300 38
		}
301
302
		return $stringDegrees;
303 38
	}
304 8
305
	/**
306
	 * @param float $floatDegrees
307 38
	 * @param float|int $precision
308
	 *
309
	 * @return string
310
	 */
311
	private function getInDecimalDegreeFormat( $floatDegrees, $precision ) {
312
		$degreeDigits = $this->getSignificantDigits( 1, $precision );
313
		$stringDegrees = $this->formatNumber( $floatDegrees, $degreeDigits );
314
315
		return $stringDegrees . $this->options->getOption( self::OPT_DEGREE_SYMBOL );
316 42
	}
317 42
318 42
	/**
319
	 * @param float $floatDegrees
320 42
	 * @param float|int $precision
321
	 *
322
	 * @return string
323
	 */
324
	private function getInDegreeMinuteSecondFormat( $floatDegrees, $precision ) {
325
		$isNegative = $floatDegrees < 0;
326
		$secondDigits = $this->getSignificantDigits( 3600, $precision );
327
328
		$seconds = round( abs( $floatDegrees ) * 3600, max( 0, $secondDigits ) );
329 34
		$minutes = (int)( $seconds / 60 );
330 34
		$degrees = (int)( $minutes / 60 );
331 34
332
		$seconds -= $minutes * 60;
333 34
		$minutes -= $degrees * 60;
334 34
335 34
		$space = $this->getSpacing( self::OPT_SPACE_COORDPARTS );
336
		$result = $this->formatNumber( $degrees )
337 34
			. $this->options->getOption( self::OPT_DEGREE_SYMBOL )
338 34
			. $space
339
			. $this->formatNumber( $minutes )
340 34
			. $this->options->getOption( self::OPT_MINUTE_SYMBOL )
341 34
			. $space
342 34
			. $this->formatNumber( $seconds, $secondDigits )
343 34
			. $this->options->getOption( self::OPT_SECOND_SYMBOL );
344 34
345 34
		if ( $isNegative && ( $degrees + $minutes + $seconds ) > 0 ) {
346 34
			$result = '-' . $result;
347 34
		}
348 34
349
		return $result;
350 34
	}
351 16
352
	/**
353
	 * @param float $floatDegrees
354 34
	 * @param float|int $precision
355
	 *
356
	 * @return string
357
	 */
358
	private function getInDecimalMinuteFormat( $floatDegrees, $precision ) {
359
		$isNegative = $floatDegrees < 0;
360
		$minuteDigits = $this->getSignificantDigits( 60, $precision );
361
362
		$minutes = round( abs( $floatDegrees ) * 60, max( 0, $minuteDigits ) );
363 42
		$degrees = (int)( $minutes / 60 );
364 42
365 42
		$minutes -= $degrees * 60;
366
367 42
		$space = $this->getSpacing( self::OPT_SPACE_COORDPARTS );
368 42
		$result = $this->formatNumber( $degrees )
369
			. $this->options->getOption( self::OPT_DEGREE_SYMBOL )
370 42
			. $space
371
			. $this->formatNumber( $minutes, $minuteDigits )
372 42
			. $this->options->getOption( self::OPT_MINUTE_SYMBOL );
373 42
374 42
		if ( $isNegative && ( $degrees + $minutes ) > 0 ) {
375 42
			$result = '-' . $result;
376 42
		}
377 42
378
		return $result;
379 42
	}
380 24
381
	/**
382
	 * @param float|int $unitsPerDegree The number of target units per degree
383 42
	 * (60 for minutes, 3600 for seconds)
384
	 * @param float|int $degreePrecision
385
	 *
386
	 * @return int The number of digits to show after the decimal point
387
	 * (resp. before, if the result is negative).
388
	 */
389
	private function getSignificantDigits( $unitsPerDegree, $degreePrecision ) {
390
		return (int)ceil( -log10( $unitsPerDegree * $degreePrecision ) );
391
	}
392
393
	/**
394 118
	 * @param float $number
395 118
	 * @param int $digits The number of digits after the decimal point.
396
	 *
397
	 * @return string
398
	 */
399
	private function formatNumber( $number, $digits = 0 ) {
400
		// TODO: use NumberLocalizer
401
		return sprintf( '%.' . ( $digits > 0 ? $digits : 0 ) . 'F', $number );
402
	}
403
404
}
405