Passed
Push — master ( 8b0415...bcec7e )
by Daniel
38s
created

GeoCoordinateFormatter::getInDecimalMinuteFormat()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 20
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 20
rs 9.4285
cc 3
eloc 15
nc 2
nop 2
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
24
 *
25
 * @license GPL-2.0+
26
 * @author Jeroen De Dauw < [email protected] >
27
 * @author Addshore
28
 * @author Thiemo Mättig
29
 */
30
class GeoCoordinateFormatter 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
	/**
93
	 * @since 0.1
94
	 *
95
	 * @param FormatterOptions|null $options
96
	 */
97
	public function __construct( FormatterOptions $options = null ) {
98
		parent::__construct( $options );
99
100
		$this->defaultOption( self::OPT_NORTH_SYMBOL, 'N' );
101
		$this->defaultOption( self::OPT_EAST_SYMBOL, 'E' );
102
		$this->defaultOption( self::OPT_SOUTH_SYMBOL, 'S' );
103
		$this->defaultOption( self::OPT_WEST_SYMBOL, 'W' );
104
105
		$this->defaultOption( self::OPT_DEGREE_SYMBOL, '°' );
106
		$this->defaultOption( self::OPT_MINUTE_SYMBOL, "'" );
107
		$this->defaultOption( self::OPT_SECOND_SYMBOL, '"' );
108
109
		$this->defaultOption( self::OPT_FORMAT, self::TYPE_FLOAT );
110
		$this->defaultOption( self::OPT_DIRECTIONAL, false );
111
112
		$this->defaultOption( self::OPT_SEPARATOR_SYMBOL, ',' );
113
		$this->defaultOption( self::OPT_SPACING_LEVEL, array(
114
			self::OPT_SPACE_LATLONG,
115
			self::OPT_SPACE_DIRECTION,
116
			self::OPT_SPACE_COORDPARTS,
117
		) );
118
		$this->defaultOption( self::OPT_PRECISION, 0 );
119
	}
120
121
	/**
122
	 * @see ValueFormatter::format
123
	 *
124
	 * Calls formatLatLongValue() using OPT_PRECISION for the $precision parameter.
125
	 *
126
	 * @param LatLongValue $value
127
	 *
128
	 * @return string Plain text
129
	 * @throws InvalidArgumentException
130
	 */
131
	public function format( $value ) {
132
		if ( !( $value instanceof LatLongValue ) ) {
133
			throw new InvalidArgumentException( 'Data value type mismatch. Expected a LatLongValue.' );
134
		}
135
136
		$precision = $this->options->getOption( self::OPT_PRECISION );
137
138
		return $this->formatLatLongValue( $value, $precision );
139
	}
140
141
	/**
142
	 * Formats a LatLongValue with the desired precision.
143
	 *
144
	 * @since 0.5
145
	 *
146
	 * @param LatLongValue $value
147
	 * @param float|int $precision The desired precision, given as fractional degrees.
148
	 *
149
	 * @return string Plain text
150
	 * @throws InvalidArgumentException
151
	 */
152
	public function formatLatLongValue( LatLongValue $value, $precision ) {
153
		if ( $precision <= 0 || !is_finite( $precision ) ) {
154
			$precision = 1 / 3600;
155
		}
156
157
		$formatted = implode(
158
			$this->getOption( self::OPT_SEPARATOR_SYMBOL ) . $this->getSpacing( self::OPT_SPACE_LATLONG ),
159
			array(
160
				$this->formatLatitude( $value->getLatitude(), $precision ),
161
				$this->formatLongitude( $value->getLongitude(), $precision )
162
			)
163
		);
164
165
		return $formatted;
166
	}
167
168
	/**
169
	 * @param string $spacingLevel One of the self::OPT_SPACE_… constants
170
	 *
171
	 * @return string
172
	 */
173
	private function getSpacing( $spacingLevel ) {
174
		if ( in_array( $spacingLevel, $this->getOption( self::OPT_SPACING_LEVEL ) ) ) {
175
			return ' ';
176
		}
177
		return '';
178
	}
179
180
	/**
181
	 * @param float $latitude
182
	 * @param float|int $precision
183
	 *
184
	 * @return string
185
	 */
186
	private function formatLatitude( $latitude, $precision ) {
187
		return $this->makeDirectionalIfNeeded(
188
			$this->formatCoordinate( $latitude, $precision ),
189
			$this->options->getOption( self::OPT_NORTH_SYMBOL ),
190
			$this->options->getOption( self::OPT_SOUTH_SYMBOL )
191
		);
192
	}
193
194
	/**
195
	 * @param float $longitude
196
	 * @param float|int $precision
197
	 *
198
	 * @return string
199
	 */
200
	private function formatLongitude( $longitude, $precision ) {
201
		return $this->makeDirectionalIfNeeded(
202
			$this->formatCoordinate( $longitude, $precision ),
203
			$this->options->getOption( self::OPT_EAST_SYMBOL ),
204
			$this->options->getOption( self::OPT_WEST_SYMBOL )
205
		);
206
	}
207
208
	/**
209
	 * @param string $coordinate
210
	 * @param string $positiveSymbol
211
	 * @param string $negativeSymbol
212
	 *
213
	 * @return string
214
	 */
215
	private function makeDirectionalIfNeeded( $coordinate, $positiveSymbol, $negativeSymbol ) {
216
		if ( $this->options->getOption( self::OPT_DIRECTIONAL ) ) {
217
			return $this->makeDirectional( $coordinate, $positiveSymbol, $negativeSymbol );
218
		}
219
220
		return $coordinate;
221
	}
222
223
	/**
224
	 * @param string $coordinate
225
	 * @param string $positiveSymbol
226
	 * @param string $negativeSymbol
227
	 *
228
	 * @return string
229
	 */
230
	private function makeDirectional( $coordinate, $positiveSymbol, $negativeSymbol ) {
231
		$isNegative = substr( $coordinate, 0, 1 ) === '-';
232
233
		if ( $isNegative ) {
234
			$coordinate = substr( $coordinate, 1 );
235
		}
236
237
		$symbol = $isNegative ? $negativeSymbol : $positiveSymbol;
238
239
		return $coordinate . $this->getSpacing( self::OPT_SPACE_DIRECTION ) . $symbol;
240
	}
241
242
	/**
243
	 * @param float $degrees
244
	 * @param float|int $precision
245
	 *
246
	 * @return string
247
	 */
248
	private function formatCoordinate( $degrees, $precision ) {
249
		// Remove insignificant detail
250
		$degrees = $this->roundDegrees( $degrees, $precision );
251
		$format = $this->getOption( self::OPT_FORMAT );
252
253
		if ( $format === self::TYPE_FLOAT ) {
254
			return $this->getInFloatFormat( $degrees );
255
		}
256
257
		if ( $format === self::TYPE_DD || $precision >= 1 ) {
258
			return $this->getInDecimalDegreeFormat( $degrees, $precision );
259
		}
260
		if ( $format === self::TYPE_DM || $precision >= 1 / 60 ) {
261
			return $this->getInDecimalMinuteFormat( $degrees, $precision );
262
		}
263
		if ( $format === self::TYPE_DMS ) {
264
			return $this->getInDegreeMinuteSecondFormat( $degrees, $precision );
265
		}
266
267
		throw new InvalidArgumentException( 'Invalid coordinate format specified in the options' );
268
	}
269
270
	/**
271
	 * Round degrees according to OPT_PRECISION
272
	 *
273
	 * @param float $degrees
274
	 * @param float|int $precision
275
	 *
276
	 * @return float
277
	 */
278
	private function roundDegrees( $degrees, $precision ) {
279
		$sign = $degrees > 0 ? 1 : -1;
280
		$reduced = round( abs( $degrees ) / $precision );
281
		$expanded = $reduced * $precision;
282
283
		return $sign * $expanded;
284
	}
285
286
	/**
287
	 * @param float $floatDegrees
288
	 *
289
	 * @return string
290
	 */
291
	private function getInFloatFormat( $floatDegrees ) {
292
		$stringDegrees = (string)$floatDegrees;
293
294
		// Floats are fun...
295
		if ( $stringDegrees === '-0' ) {
296
			$stringDegrees = '0';
297
		}
298
299
		return $stringDegrees;
300
	}
301
302
	/**
303
	 * @param float $floatDegrees
304
	 * @param float|int $precision
305
	 *
306
	 * @return string
307
	 */
308
	private function getInDecimalDegreeFormat( $floatDegrees, $precision ) {
309
		$degreeDigits = $this->getSignificantDigits( 1, $precision );
310
		$stringDegrees = $this->formatNumber( $floatDegrees, $degreeDigits );
311
312
		return $stringDegrees . $this->options->getOption( self::OPT_DEGREE_SYMBOL );
313
	}
314
315
	/**
316
	 * @param float $floatDegrees
317
	 * @param float|int $precision
318
	 *
319
	 * @return string
320
	 */
321
	private function getInDegreeMinuteSecondFormat( $floatDegrees, $precision ) {
322
		$isNegative = $floatDegrees < 0;
323
		$secondDigits = $this->getSignificantDigits( 3600, $precision );
324
		$seconds = round( abs( $floatDegrees ) * 3600, max( 0, $secondDigits ) );
325
		$minutes = (int)( $seconds / 60 );
326
		$degrees = (int)( $minutes / 60 );
327
		$seconds -= $minutes * 60;
328
		$minutes -= $degrees * 60;
329
330
		$space = $this->getSpacing( self::OPT_SPACE_COORDPARTS );
331
		$result = $this->formatNumber( $degrees )
332
			. $this->options->getOption( self::OPT_DEGREE_SYMBOL )
333
			. $space
334
			. $this->formatNumber( $minutes )
335
			. $this->options->getOption( self::OPT_MINUTE_SYMBOL )
336
			. $space
337
			. $this->formatNumber( $seconds, $secondDigits )
338
			. $this->options->getOption( self::OPT_SECOND_SYMBOL );
339
340
		if ( $isNegative && ( $degrees + $minutes + $seconds ) > 0 ) {
341
			$result = '-' . $result;
342
		}
343
344
		return $result;
345
	}
346
347
	/**
348
	 * @param float $floatDegrees
349
	 * @param float|int $precision
350
	 *
351
	 * @return string
352
	 */
353
	private function getInDecimalMinuteFormat( $floatDegrees, $precision ) {
354
		$isNegative = $floatDegrees < 0;
355
		$minuteDigits = $this->getSignificantDigits( 60, $precision );
356
		$minutes = round( abs( $floatDegrees ) * 60, max( 0, $minuteDigits ) );
357
		$degrees = (int)( $minutes / 60 );
358
		$minutes -= $degrees * 60;
359
360
		$space = $this->getSpacing( self::OPT_SPACE_COORDPARTS );
361
		$result = $this->formatNumber( $degrees )
362
			. $this->options->getOption( self::OPT_DEGREE_SYMBOL )
363
			. $space
364
			. $this->formatNumber( $minutes, $minuteDigits )
365
			. $this->options->getOption( self::OPT_MINUTE_SYMBOL );
366
367
		if ( $isNegative && ( $degrees + $minutes ) > 0 ) {
368
			$result = '-' . $result;
369
		}
370
371
		return $result;
372
	}
373
374
	/**
375
	 * @param float|int $unitsPerDegree The number of target units per degree
376
	 * (60 for minutes, 3600 for seconds)
377
	 * @param float|int $degreePrecision
378
	 *
379
	 * @return int The number of digits to show after the decimal point
380
	 * (resp. before, if the result is negative).
381
	 */
382
	private function getSignificantDigits( $unitsPerDegree, $degreePrecision ) {
383
		return (int)ceil( -log10( $unitsPerDegree * $degreePrecision ) );
384
	}
385
386
	/**
387
	 * @param float $number
388
	 * @param int $digits The number of digits after the decimal point.
389
	 *
390
	 * @return string
391
	 */
392
	private function formatNumber( $number, $digits = 0 ) {
393
		// TODO: use NumberLocalizer
394
		return sprintf( '%.' . ( $digits > 0 ? $digits : 0 ) . 'F', $number );
395
	}
396
397
}
398