Passed
Push — master ( afd872...5de271 )
by Marius
58s queued 12s
created

LatLongFormatter::getUpdatedPrecision()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 5

Importance

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