001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.projection;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.util.ArrayList;
007import java.util.Arrays;
008import java.util.EnumMap;
009import java.util.HashMap;
010import java.util.List;
011import java.util.Map;
012import java.util.Optional;
013import java.util.concurrent.ConcurrentHashMap;
014import java.util.regex.Matcher;
015import java.util.regex.Pattern;
016
017import org.openstreetmap.josm.data.Bounds;
018import org.openstreetmap.josm.data.ProjectionBounds;
019import org.openstreetmap.josm.data.coor.EastNorth;
020import org.openstreetmap.josm.data.coor.LatLon;
021import org.openstreetmap.josm.data.coor.conversion.LatLonParser;
022import org.openstreetmap.josm.data.projection.datum.CentricDatum;
023import org.openstreetmap.josm.data.projection.datum.Datum;
024import org.openstreetmap.josm.data.projection.datum.NTV2Datum;
025import org.openstreetmap.josm.data.projection.datum.NullDatum;
026import org.openstreetmap.josm.data.projection.datum.SevenParameterDatum;
027import org.openstreetmap.josm.data.projection.datum.ThreeParameterDatum;
028import org.openstreetmap.josm.data.projection.datum.WGS84Datum;
029import org.openstreetmap.josm.data.projection.proj.ICentralMeridianProvider;
030import org.openstreetmap.josm.data.projection.proj.IScaleFactorProvider;
031import org.openstreetmap.josm.data.projection.proj.Mercator;
032import org.openstreetmap.josm.data.projection.proj.Proj;
033import org.openstreetmap.josm.data.projection.proj.ProjParameters;
034import org.openstreetmap.josm.tools.JosmRuntimeException;
035import org.openstreetmap.josm.tools.Logging;
036import org.openstreetmap.josm.tools.Utils;
037import org.openstreetmap.josm.tools.bugreport.BugReport;
038
039/**
040 * Custom projection.
041 *
042 * Inspired by PROJ.4 and Proj4J.
043 * @since 5072
044 */
045public class CustomProjection extends AbstractProjection {
046
047    /*
048     * Equation for METER_PER_UNIT_DEGREE taken from:
049     * https://github.com/openlayers/ol3/blob/master/src/ol/proj/epsg4326projection.js#L58
050     * Value for Radius taken form:
051     * https://github.com/openlayers/ol3/blob/master/src/ol/sphere/wgs84sphere.js#L11
052     */
053    private static final double METER_PER_UNIT_DEGREE = 2 * Math.PI * 6378137.0 / 360;
054    private static final Map<String, Double> UNITS_TO_METERS = getUnitsToMeters();
055    private static final Map<String, Double> PRIME_MERIDANS = getPrimeMeridians();
056
057    /**
058     * pref String that defines the projection
059     *
060     * null means fall back mode (Mercator)
061     */
062    protected String pref;
063    protected String name;
064    protected String code;
065    protected Bounds bounds;
066    private double metersPerUnitWMTS;
067    private String axis = "enu"; // default axis orientation is East, North, Up
068
069    private static final List<String> LON_LAT_VALUES = Arrays.asList("longlat", "latlon", "latlong");
070
071    /**
072     * Proj4-like projection parameters. See <a href="https://trac.osgeo.org/proj/wiki/GenParms">reference</a>.
073     * @since 7370 (public)
074     */
075    public enum Param {
076
077        /** False easting */
078        x_0("x_0", true),
079        /** False northing */
080        y_0("y_0", true),
081        /** Central meridian */
082        lon_0("lon_0", true),
083        /** Prime meridian */
084        pm("pm", true),
085        /** Scaling factor */
086        k_0("k_0", true),
087        /** Ellipsoid name (see {@code proj -le}) */
088        ellps("ellps", true),
089        /** Semimajor radius of the ellipsoid axis */
090        a("a", true),
091        /** Eccentricity of the ellipsoid squared */
092        es("es", true),
093        /** Reciprocal of the ellipsoid flattening term (e.g. 298) */
094        rf("rf", true),
095        /** Flattening of the ellipsoid = 1-sqrt(1-e^2) */
096        f("f", true),
097        /** Semiminor radius of the ellipsoid axis */
098        b("b", true),
099        /** Datum name (see {@code proj -ld}) */
100        datum("datum", true),
101        /** 3 or 7 term datum transform parameters */
102        towgs84("towgs84", true),
103        /** Filename of NTv2 grid file to use for datum transforms */
104        nadgrids("nadgrids", true),
105        /** Projection name (see {@code proj -l}) */
106        proj("proj", true),
107        /** Latitude of origin */
108        lat_0("lat_0", true),
109        /** Latitude of first standard parallel */
110        lat_1("lat_1", true),
111        /** Latitude of second standard parallel */
112        lat_2("lat_2", true),
113        /** Latitude of true scale (Polar Stereographic) */
114        lat_ts("lat_ts", true),
115        /** longitude of the center of the projection (Oblique Mercator) */
116        lonc("lonc", true),
117        /** azimuth (true) of the center line passing through the center of the
118         * projection (Oblique Mercator) */
119        alpha("alpha", true),
120        /** rectified bearing of the center line (Oblique Mercator) */
121        gamma("gamma", true),
122        /** select "Hotine" variant of Oblique Mercator */
123        no_off("no_off", false),
124        /** legacy alias for no_off */
125        no_uoff("no_uoff", false),
126        /** longitude of first point (Oblique Mercator) */
127        lon_1("lon_1", true),
128        /** longitude of second point (Oblique Mercator) */
129        lon_2("lon_2", true),
130        /** the exact proj.4 string will be preserved in the WKT representation */
131        wktext("wktext", false),  // ignored
132        /** meters, US survey feet, etc. */
133        units("units", true),
134        /** Don't use the /usr/share/proj/proj_def.dat defaults file */
135        no_defs("no_defs", false),
136        init("init", true),
137        /** crs units to meter multiplier */
138        to_meter("to_meter", true),
139        /** definition of axis for projection */
140        axis("axis", true),
141        /** UTM zone */
142        zone("zone", true),
143        /** indicate southern hemisphere for UTM */
144        south("south", false),
145        /** vertical units - ignore, as we don't use height information */
146        vunits("vunits", true),
147        // JOSM extensions, not present in PROJ.4
148        wmssrs("wmssrs", true),
149        bounds("bounds", true);
150
151        /** Parameter key */
152        public final String key;
153        /** {@code true} if the parameter has a value */
154        public final boolean hasValue;
155
156        /** Map of all parameters by key */
157        static final Map<String, Param> paramsByKey = new ConcurrentHashMap<>();
158        static {
159            for (Param p : Param.values()) {
160                paramsByKey.put(p.key, p);
161            }
162            // alias
163            paramsByKey.put("k", Param.k_0);
164        }
165
166        Param(String key, boolean hasValue) {
167            this.key = key;
168            this.hasValue = hasValue;
169        }
170    }
171
172    enum Polarity {
173        NORTH(LatLon.NORTH_POLE),
174        SOUTH(LatLon.SOUTH_POLE);
175
176        private final LatLon latlon;
177
178        Polarity(LatLon latlon) {
179            this.latlon = latlon;
180        }
181
182        LatLon getLatLon() {
183            return latlon;
184        }
185    }
186
187    private EnumMap<Polarity, EastNorth> polesEN;
188
189    /**
190     * Constructs a new empty {@code CustomProjection}.
191     */
192    public CustomProjection() {
193        // contents can be set later with update()
194    }
195
196    /**
197     * Constructs a new {@code CustomProjection} with given parameters.
198     * @param pref String containing projection parameters
199     * (ex: "+proj=tmerc +lon_0=-3 +k_0=0.9996 +x_0=500000 +ellps=WGS84 +datum=WGS84 +bounds=-8,-5,2,85")
200     */
201    public CustomProjection(String pref) {
202        this(null, null, pref);
203    }
204
205    /**
206     * Constructs a new {@code CustomProjection} with given name, code and parameters.
207     *
208     * @param name describe projection in one or two words
209     * @param code unique code for this projection - may be null
210     * @param pref the string that defines the custom projection
211     */
212    public CustomProjection(String name, String code, String pref) {
213        this.name = name;
214        this.code = code;
215        this.pref = pref;
216        try {
217            update(pref);
218        } catch (ProjectionConfigurationException ex) {
219            Logging.trace(ex);
220            try {
221                update(null);
222            } catch (ProjectionConfigurationException ex1) {
223                throw BugReport.intercept(ex1).put("name", name).put("code", code).put("pref", pref);
224            }
225        }
226    }
227
228    /**
229     * Updates this {@code CustomProjection} with given parameters.
230     * @param pref String containing projection parameters (ex: "+proj=lonlat +ellps=WGS84 +datum=WGS84 +bounds=-180,-90,180,90")
231     * @throws ProjectionConfigurationException if {@code pref} cannot be parsed properly
232     */
233    public final void update(String pref) throws ProjectionConfigurationException {
234        this.pref = pref;
235        if (pref == null) {
236            ellps = Ellipsoid.WGS84;
237            datum = WGS84Datum.INSTANCE;
238            proj = new Mercator();
239            bounds = new Bounds(
240                    -85.05112877980659, -180.0,
241                    85.05112877980659, 180.0, true);
242        } else {
243            Map<String, String> parameters = parseParameterList(pref, false);
244            parameters = resolveInits(parameters, false);
245            ellps = parseEllipsoid(parameters);
246            datum = parseDatum(parameters, ellps);
247            if (ellps == null) {
248                ellps = datum.getEllipsoid();
249            }
250            proj = parseProjection(parameters, ellps);
251            // "utm" is a shortcut for a set of parameters
252            if ("utm".equals(parameters.get(Param.proj.key))) {
253                Integer zone;
254                try {
255                    zone = Integer.valueOf(Optional.ofNullable(parameters.get(Param.zone.key)).orElseThrow(
256                            () -> new ProjectionConfigurationException(tr("UTM projection (''+proj=utm'') requires ''+zone=...'' parameter."))));
257                } catch (NumberFormatException e) {
258                    zone = null;
259                }
260                if (zone == null || zone < 1 || zone > 60)
261                    throw new ProjectionConfigurationException(tr("Expected integer value in range 1-60 for ''+zone=...'' parameter."));
262                this.lon0 = 6d * zone - 183d;
263                this.k0 = 0.9996;
264                this.x0 = 500_000;
265                this.y0 = parameters.containsKey(Param.south.key) ? 10_000_000 : 0;
266            }
267            String s = parameters.get(Param.x_0.key);
268            if (s != null) {
269                this.x0 = parseDouble(s, Param.x_0.key);
270            }
271            s = parameters.get(Param.y_0.key);
272            if (s != null) {
273                this.y0 = parseDouble(s, Param.y_0.key);
274            }
275            s = parameters.get(Param.lon_0.key);
276            if (s != null) {
277                this.lon0 = parseAngle(s, Param.lon_0.key);
278            }
279            if (proj instanceof ICentralMeridianProvider) {
280                this.lon0 = ((ICentralMeridianProvider) proj).getCentralMeridian();
281            }
282            s = parameters.get(Param.pm.key);
283            if (s != null) {
284                if (PRIME_MERIDANS.containsKey(s)) {
285                    this.pm = PRIME_MERIDANS.get(s);
286                } else {
287                    this.pm = parseAngle(s, Param.pm.key);
288                }
289            }
290            s = parameters.get(Param.k_0.key);
291            if (s != null) {
292                this.k0 = parseDouble(s, Param.k_0.key);
293            }
294            if (proj instanceof IScaleFactorProvider) {
295                this.k0 *= ((IScaleFactorProvider) proj).getScaleFactor();
296            }
297            s = parameters.get(Param.bounds.key);
298            if (s != null) {
299                this.bounds = parseBounds(s);
300            }
301            s = parameters.get(Param.wmssrs.key);
302            if (s != null) {
303                this.code = s;
304            }
305            boolean defaultUnits = true;
306            s = parameters.get(Param.units.key);
307            if (s != null) {
308                s = Utils.strip(s, "\"");
309                if (UNITS_TO_METERS.containsKey(s)) {
310                    this.toMeter = UNITS_TO_METERS.get(s);
311                    this.metersPerUnitWMTS = this.toMeter;
312                    defaultUnits = false;
313                } else {
314                    throw new ProjectionConfigurationException(tr("No unit found for: {0}", s));
315                }
316            }
317            s = parameters.get(Param.to_meter.key);
318            if (s != null) {
319                this.toMeter = parseDouble(s, Param.to_meter.key);
320                this.metersPerUnitWMTS = this.toMeter;
321                defaultUnits = false;
322            }
323            if (defaultUnits) {
324                this.toMeter = 1;
325                this.metersPerUnitWMTS = proj.isGeographic() ? METER_PER_UNIT_DEGREE : 1;
326            }
327            s = parameters.get(Param.axis.key);
328            if (s != null) {
329                this.axis = s;
330            }
331        }
332    }
333
334    /**
335     * Parse a parameter list to key=value pairs.
336     *
337     * @param pref the parameter list
338     * @param ignoreUnknownParameter true, if unknown parameter should not raise exception
339     * @return parameters map
340     * @throws ProjectionConfigurationException in case of invalid parameter
341     */
342    public static Map<String, String> parseParameterList(String pref, boolean ignoreUnknownParameter) throws ProjectionConfigurationException {
343        Map<String, String> parameters = new HashMap<>();
344        String trimmedPref = pref.trim();
345        if (trimmedPref.isEmpty()) {
346            return parameters;
347        }
348
349        Pattern keyPattern = Pattern.compile("\\+(?<key>[a-zA-Z0-9_]+)(=(?<value>.*))?");
350        String[] parts = Utils.WHITE_SPACES_PATTERN.split(trimmedPref);
351        for (String part : parts) {
352            Matcher m = keyPattern.matcher(part);
353            if (m.matches()) {
354                String key = m.group("key");
355                String value = m.group("value");
356                // some aliases
357                if (key.equals(Param.proj.key) && LON_LAT_VALUES.contains(value)) {
358                    value = "lonlat";
359                }
360                Param param = Param.paramsByKey.get(key);
361                if (param == null) {
362                    if (!ignoreUnknownParameter)
363                        throw new ProjectionConfigurationException(tr("Unknown parameter: ''{0}''.", key));
364                } else {
365                    if (param.hasValue && value == null)
366                        throw new ProjectionConfigurationException(tr("Value expected for parameter ''{0}''.", key));
367                    if (!param.hasValue && value != null)
368                        throw new ProjectionConfigurationException(tr("No value expected for parameter ''{0}''.", key));
369                    key = param.key; // To be really sure, we might have an alias.
370                }
371                parameters.put(key, value);
372            } else if (!part.startsWith("+")) {
373                throw new ProjectionConfigurationException(tr("Parameter must begin with a ''+'' character (found ''{0}'')", part));
374            } else {
375                throw new ProjectionConfigurationException(tr("Unexpected parameter format (''{0}'')", part));
376            }
377        }
378        return parameters;
379    }
380
381    /**
382     * Recursive resolution of +init includes.
383     *
384     * @param parameters parameters map
385     * @param ignoreUnknownParameter true, if unknown parameter should not raise exception
386     * @return parameters map with +init includes resolved
387     * @throws ProjectionConfigurationException in case of invalid parameter
388     */
389    public static Map<String, String> resolveInits(Map<String, String> parameters, boolean ignoreUnknownParameter)
390            throws ProjectionConfigurationException {
391        // recursive resolution of +init includes
392        String initKey = parameters.get(Param.init.key);
393        if (initKey != null) {
394            Map<String, String> initp;
395            try {
396                initp = parseParameterList(Optional.ofNullable(Projections.getInit(initKey)).orElseThrow(
397                        () -> new ProjectionConfigurationException(tr("Value ''{0}'' for option +init not supported.", initKey))),
398                        ignoreUnknownParameter);
399                initp = resolveInits(initp, ignoreUnknownParameter);
400            } catch (ProjectionConfigurationException ex) {
401                throw new ProjectionConfigurationException(initKey+": "+ex.getMessage(), ex);
402            }
403            initp.putAll(parameters);
404            return initp;
405        }
406        return parameters;
407    }
408
409    /**
410     * Gets the ellipsoid
411     * @param parameters The parameters to get the value from
412     * @return The Ellipsoid as specified with the parameters
413     * @throws ProjectionConfigurationException in case of invalid parameters
414     */
415    public Ellipsoid parseEllipsoid(Map<String, String> parameters) throws ProjectionConfigurationException {
416        String code = parameters.get(Param.ellps.key);
417        if (code != null) {
418            return Optional.ofNullable(Projections.getEllipsoid(code)).orElseThrow(
419                () -> new ProjectionConfigurationException(tr("Ellipsoid ''{0}'' not supported.", code)));
420        }
421        String s = parameters.get(Param.a.key);
422        if (s != null) {
423            double a = parseDouble(s, Param.a.key);
424            if (parameters.get(Param.es.key) != null) {
425                double es = parseDouble(parameters, Param.es.key);
426                return Ellipsoid.createAes(a, es);
427            }
428            if (parameters.get(Param.rf.key) != null) {
429                double rf = parseDouble(parameters, Param.rf.key);
430                return Ellipsoid.createArf(a, rf);
431            }
432            if (parameters.get(Param.f.key) != null) {
433                double f = parseDouble(parameters, Param.f.key);
434                return Ellipsoid.createAf(a, f);
435            }
436            if (parameters.get(Param.b.key) != null) {
437                double b = parseDouble(parameters, Param.b.key);
438                return Ellipsoid.createAb(a, b);
439            }
440        }
441        if (parameters.containsKey(Param.a.key) ||
442                parameters.containsKey(Param.es.key) ||
443                parameters.containsKey(Param.rf.key) ||
444                parameters.containsKey(Param.f.key) ||
445                parameters.containsKey(Param.b.key))
446            throw new ProjectionConfigurationException(tr("Combination of ellipsoid parameters is not supported."));
447        return null;
448    }
449
450    /**
451     * Gets the datum
452     * @param parameters The parameters to get the value from
453     * @param ellps The ellisoid that was previously computed
454     * @return The Datum as specified with the parameters
455     * @throws ProjectionConfigurationException in case of invalid parameters
456     */
457    public Datum parseDatum(Map<String, String> parameters, Ellipsoid ellps) throws ProjectionConfigurationException {
458        String datumId = parameters.get(Param.datum.key);
459        if (datumId != null) {
460            return Optional.ofNullable(Projections.getDatum(datumId)).orElseThrow(
461                    () -> new ProjectionConfigurationException(tr("Unknown datum identifier: ''{0}''", datumId)));
462        }
463        if (ellps == null) {
464            if (parameters.containsKey(Param.no_defs.key))
465                throw new ProjectionConfigurationException(tr("Ellipsoid required (+ellps=* or +a=*, +b=*)"));
466            // nothing specified, use WGS84 as default
467            ellps = Ellipsoid.WGS84;
468        }
469
470        String nadgridsId = parameters.get(Param.nadgrids.key);
471        if (nadgridsId != null) {
472            if (nadgridsId.startsWith("@")) {
473                nadgridsId = nadgridsId.substring(1);
474            }
475            if ("null".equals(nadgridsId))
476                return new NullDatum(null, ellps);
477            final String fNadgridsId = nadgridsId;
478            return new NTV2Datum(fNadgridsId, null, ellps, Optional.ofNullable(Projections.getNTV2Grid(fNadgridsId)).orElseThrow(
479                    () -> new ProjectionConfigurationException(tr("Grid shift file ''{0}'' for option +nadgrids not supported.", fNadgridsId))));
480        }
481
482        String towgs84 = parameters.get(Param.towgs84.key);
483        if (towgs84 != null)
484            return parseToWGS84(towgs84, ellps);
485
486        return new NullDatum(null, ellps);
487    }
488
489    /**
490     * Parse {@code towgs84} parameter.
491     * @param paramList List of parameter arguments (expected: 3 or 7)
492     * @param ellps ellipsoid
493     * @return parsed datum ({@link ThreeParameterDatum} or {@link SevenParameterDatum})
494     * @throws ProjectionConfigurationException if the arguments cannot be parsed
495     */
496    public Datum parseToWGS84(String paramList, Ellipsoid ellps) throws ProjectionConfigurationException {
497        String[] numStr = paramList.split(",");
498
499        if (numStr.length != 3 && numStr.length != 7)
500            throw new ProjectionConfigurationException(tr("Unexpected number of arguments for parameter ''towgs84'' (must be 3 or 7)"));
501        List<Double> towgs84Param = new ArrayList<>();
502        for (String str : numStr) {
503            try {
504                towgs84Param.add(Double.valueOf(str));
505            } catch (NumberFormatException e) {
506                throw new ProjectionConfigurationException(tr("Unable to parse value of parameter ''towgs84'' (''{0}'')", str), e);
507            }
508        }
509        boolean isCentric = true;
510        for (Double param : towgs84Param) {
511            if (param != 0) {
512                isCentric = false;
513                break;
514            }
515        }
516        if (isCentric)
517            return new CentricDatum(null, null, ellps);
518        boolean is3Param = true;
519        for (int i = 3; i < towgs84Param.size(); i++) {
520            if (towgs84Param.get(i) != 0) {
521                is3Param = false;
522                break;
523            }
524        }
525        if (is3Param)
526            return new ThreeParameterDatum(null, null, ellps,
527                    towgs84Param.get(0),
528                    towgs84Param.get(1),
529                    towgs84Param.get(2));
530        else
531            return new SevenParameterDatum(null, null, ellps,
532                    towgs84Param.get(0),
533                    towgs84Param.get(1),
534                    towgs84Param.get(2),
535                    towgs84Param.get(3),
536                    towgs84Param.get(4),
537                    towgs84Param.get(5),
538                    towgs84Param.get(6));
539    }
540
541    /**
542     * Gets a projection using the given ellipsoid
543     * @param parameters Additional parameters
544     * @param ellps The {@link Ellipsoid}
545     * @return The projection
546     * @throws ProjectionConfigurationException in case of invalid parameters
547     */
548    public Proj parseProjection(Map<String, String> parameters, Ellipsoid ellps) throws ProjectionConfigurationException {
549        String id = parameters.get(Param.proj.key);
550        if (id == null) throw new ProjectionConfigurationException(tr("Projection required (+proj=*)"));
551
552        // "utm" is not a real projection, but a shortcut for a set of parameters
553        if ("utm".equals(id)) {
554            id = "tmerc";
555        }
556        Proj proj = Projections.getBaseProjection(id);
557        if (proj == null) throw new ProjectionConfigurationException(tr("Unknown projection identifier: ''{0}''", id));
558
559        ProjParameters projParams = new ProjParameters();
560
561        projParams.ellps = ellps;
562
563        String s;
564        s = parameters.get(Param.lat_0.key);
565        if (s != null) {
566            projParams.lat0 = parseAngle(s, Param.lat_0.key);
567        }
568        s = parameters.get(Param.lat_1.key);
569        if (s != null) {
570            projParams.lat1 = parseAngle(s, Param.lat_1.key);
571        }
572        s = parameters.get(Param.lat_2.key);
573        if (s != null) {
574            projParams.lat2 = parseAngle(s, Param.lat_2.key);
575        }
576        s = parameters.get(Param.lat_ts.key);
577        if (s != null) {
578            projParams.lat_ts = parseAngle(s, Param.lat_ts.key);
579        }
580        s = parameters.get(Param.lonc.key);
581        if (s != null) {
582            projParams.lonc = parseAngle(s, Param.lonc.key);
583        }
584        s = parameters.get(Param.alpha.key);
585        if (s != null) {
586            projParams.alpha = parseAngle(s, Param.alpha.key);
587        }
588        s = parameters.get(Param.gamma.key);
589        if (s != null) {
590            projParams.gamma = parseAngle(s, Param.gamma.key);
591        }
592        s = parameters.get(Param.lon_1.key);
593        if (s != null) {
594            projParams.lon1 = parseAngle(s, Param.lon_1.key);
595        }
596        s = parameters.get(Param.lon_2.key);
597        if (s != null) {
598            projParams.lon2 = parseAngle(s, Param.lon_2.key);
599        }
600        if (parameters.containsKey(Param.no_off.key) || parameters.containsKey(Param.no_uoff.key)) {
601            projParams.no_off = Boolean.TRUE;
602        }
603        proj.initialize(projParams);
604        return proj;
605    }
606
607    /**
608     * Converts a string to a bounds object
609     * @param boundsStr The string as comma separated list of angles.
610     * @return The bounds.
611     * @throws ProjectionConfigurationException in case of invalid parameter
612     * @see CustomProjection#parseAngle(String, String)
613     */
614    public static Bounds parseBounds(String boundsStr) throws ProjectionConfigurationException {
615        String[] numStr = boundsStr.split(",");
616        if (numStr.length != 4)
617            throw new ProjectionConfigurationException(tr("Unexpected number of arguments for parameter ''+bounds'' (must be 4)"));
618        return new Bounds(parseAngle(numStr[1], "minlat (+bounds)"),
619                parseAngle(numStr[0], "minlon (+bounds)"),
620                parseAngle(numStr[3], "maxlat (+bounds)"),
621                parseAngle(numStr[2], "maxlon (+bounds)"), false);
622    }
623
624    public static double parseDouble(Map<String, String> parameters, String parameterName) throws ProjectionConfigurationException {
625        if (!parameters.containsKey(parameterName))
626            throw new ProjectionConfigurationException(tr("Unknown parameter ''{0}''", parameterName));
627        return parseDouble(Optional.ofNullable(parameters.get(parameterName)).orElseThrow(
628                () -> new ProjectionConfigurationException(tr("Expected number argument for parameter ''{0}''", parameterName))),
629                parameterName);
630    }
631
632    public static double parseDouble(String doubleStr, String parameterName) throws ProjectionConfigurationException {
633        try {
634            return Double.parseDouble(doubleStr);
635        } catch (NumberFormatException e) {
636            throw new ProjectionConfigurationException(
637                    tr("Unable to parse value ''{1}'' of parameter ''{0}'' as number.", parameterName, doubleStr), e);
638        }
639    }
640
641    /**
642     * Convert an angle string to a double value
643     * @param angleStr The string. e.g. -1.1 or 50d10'3"
644     * @param parameterName Only for error message.
645     * @return The angle value, in degrees.
646     * @throws ProjectionConfigurationException in case of invalid parameter
647     */
648    public static double parseAngle(String angleStr, String parameterName) throws ProjectionConfigurationException {
649        try {
650            return LatLonParser.parseCoordinate(angleStr);
651        } catch (IllegalArgumentException e) {
652            throw new ProjectionConfigurationException(
653                    tr("Unable to parse value ''{1}'' of parameter ''{0}'' as coordinate value.", parameterName, angleStr), e);
654        }
655    }
656
657    @Override
658    public Integer getEpsgCode() {
659        if (code != null && code.startsWith("EPSG:")) {
660            try {
661                return Integer.valueOf(code.substring(5));
662            } catch (NumberFormatException e) {
663                Logging.warn(e);
664            }
665        }
666        return null;
667    }
668
669    @Override
670    public String toCode() {
671        if (code != null) {
672            return code;
673        } else if (pref != null) {
674            return "proj:" + pref;
675        } else {
676            return "proj:ERROR";
677        }
678    }
679
680    @Override
681    public Bounds getWorldBoundsLatLon() {
682        if (bounds == null) {
683            Bounds ab = proj.getAlgorithmBounds();
684            if (ab != null) {
685                double minlon = Math.max(ab.getMinLon() + lon0 + pm, -180);
686                double maxlon = Math.min(ab.getMaxLon() + lon0 + pm, 180);
687                bounds = new Bounds(ab.getMinLat(), minlon, ab.getMaxLat(), maxlon, false);
688            } else {
689                bounds = new Bounds(
690                    new LatLon(-90.0, -180.0),
691                    new LatLon(90.0, 180.0));
692            }
693        }
694        return bounds;
695    }
696
697    @Override
698    public String toString() {
699        return name != null ? name : tr("Custom Projection");
700    }
701
702    /**
703     * Factor to convert units of east/north coordinates to meters.
704     *
705     * When east/north coordinates are in degrees (geographic CRS), the scale
706     * at the equator is taken, i.e. 360 degrees corresponds to the length of
707     * the equator in meters.
708     *
709     * @return factor to convert units to meter
710     */
711    @Override
712    public double getMetersPerUnit() {
713        return metersPerUnitWMTS;
714    }
715
716    @Override
717    public boolean switchXY() {
718        // TODO: support for other axis orientation such as West South, and Up Down
719        return this.axis.startsWith("ne");
720    }
721
722    private static Map<String, Double> getUnitsToMeters() {
723        Map<String, Double> ret = new ConcurrentHashMap<>();
724        ret.put("km", 1000d);
725        ret.put("m", 1d);
726        ret.put("dm", 1d/10);
727        ret.put("cm", 1d/100);
728        ret.put("mm", 1d/1000);
729        ret.put("kmi", 1852.0);
730        ret.put("in", 0.0254);
731        ret.put("ft", 0.3048);
732        ret.put("yd", 0.9144);
733        ret.put("mi", 1609.344);
734        ret.put("fathom", 1.8288);
735        ret.put("chain", 20.1168);
736        ret.put("link", 0.201168);
737        ret.put("us-in", 1d/39.37);
738        ret.put("us-ft", 0.304800609601219);
739        ret.put("us-yd", 0.914401828803658);
740        ret.put("us-ch", 20.11684023368047);
741        ret.put("us-mi", 1609.347218694437);
742        ret.put("ind-yd", 0.91439523);
743        ret.put("ind-ft", 0.30479841);
744        ret.put("ind-ch", 20.11669506);
745        ret.put("degree", METER_PER_UNIT_DEGREE);
746        return ret;
747    }
748
749    private static Map<String, Double> getPrimeMeridians() {
750        Map<String, Double> ret = new ConcurrentHashMap<>();
751        try {
752            ret.put("greenwich", 0.0);
753            ret.put("lisbon", parseAngle("9d07'54.862\"W", null));
754            ret.put("paris", parseAngle("2d20'14.025\"E", null));
755            ret.put("bogota", parseAngle("74d04'51.3\"W", null));
756            ret.put("madrid", parseAngle("3d41'16.58\"W", null));
757            ret.put("rome", parseAngle("12d27'8.4\"E", null));
758            ret.put("bern", parseAngle("7d26'22.5\"E", null));
759            ret.put("jakarta", parseAngle("106d48'27.79\"E", null));
760            ret.put("ferro", parseAngle("17d40'W", null));
761            ret.put("brussels", parseAngle("4d22'4.71\"E", null));
762            ret.put("stockholm", parseAngle("18d3'29.8\"E", null));
763            ret.put("athens", parseAngle("23d42'58.815\"E", null));
764            ret.put("oslo", parseAngle("10d43'22.5\"E", null));
765        } catch (ProjectionConfigurationException ex) {
766            throw new IllegalStateException(ex);
767        }
768        return ret;
769    }
770
771    private static EastNorth getPointAlong(int i, int n, ProjectionBounds r) {
772        double dEast = (r.maxEast - r.minEast) / n;
773        double dNorth = (r.maxNorth - r.minNorth) / n;
774        if (i < n) {
775            return new EastNorth(r.minEast + i * dEast, r.minNorth);
776        } else if (i < 2*n) {
777            i -= n;
778            return new EastNorth(r.maxEast, r.minNorth + i * dNorth);
779        } else if (i < 3*n) {
780            i -= 2*n;
781            return new EastNorth(r.maxEast - i * dEast, r.maxNorth);
782        } else if (i < 4*n) {
783            i -= 3*n;
784            return new EastNorth(r.minEast, r.maxNorth - i * dNorth);
785        } else {
786            throw new AssertionError();
787        }
788    }
789
790    private EastNorth getPole(Polarity whichPole) {
791        if (polesEN == null) {
792            polesEN = new EnumMap<>(Polarity.class);
793            for (Polarity p : Polarity.values()) {
794                polesEN.put(p, null);
795                LatLon ll = p.getLatLon();
796                try {
797                    EastNorth enPole = latlon2eastNorth(ll);
798                    if (enPole.isValid()) {
799                        // project back and check if the result is somewhat reasonable
800                        LatLon llBack = eastNorth2latlon(enPole);
801                        if (llBack.isValid() && ll.greatCircleDistance(llBack) < 1000) {
802                            polesEN.put(p, enPole);
803                        }
804                    }
805                } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) {
806                    Logging.error(e);
807                }
808            }
809        }
810        return polesEN.get(whichPole);
811    }
812
813    @Override
814    public Bounds getLatLonBoundsBox(ProjectionBounds r) {
815        final int n = 10;
816        Bounds result = new Bounds(eastNorth2latlon(r.getMin()));
817        result.extend(eastNorth2latlon(r.getMax()));
818        LatLon llPrev = null;
819        for (int i = 0; i < 4*n; i++) {
820            LatLon llNow = eastNorth2latlon(getPointAlong(i, n, r));
821            result.extend(llNow);
822            // check if segment crosses 180th meridian and if so, make sure
823            // to extend bounds to +/-180 degrees longitude
824            if (llPrev != null) {
825                double lon1 = llPrev.lon();
826                double lon2 = llNow.lon();
827                if (90 < lon1 && lon1 < 180 && -180 < lon2 && lon2 < -90) {
828                    result.extend(new LatLon(llPrev.lat(), 180));
829                    result.extend(new LatLon(llNow.lat(), -180));
830                }
831                if (90 < lon2 && lon2 < 180 && -180 < lon1 && lon1 < -90) {
832                    result.extend(new LatLon(llNow.lat(), 180));
833                    result.extend(new LatLon(llPrev.lat(), -180));
834                }
835            }
836            llPrev = llNow;
837        }
838        // if the box contains one of the poles, the above method did not get
839        // correct min/max latitude value
840        for (Polarity p : Polarity.values()) {
841            EastNorth pole = getPole(p);
842            if (pole != null && r.contains(pole)) {
843                result.extend(p.getLatLon());
844            }
845        }
846        return result;
847    }
848
849    @Override
850    public ProjectionBounds getEastNorthBoundsBox(ProjectionBounds box, Projection boxProjection) {
851        final int n = 8;
852        ProjectionBounds result = null;
853        for (int i = 0; i < 4*n; i++) {
854            EastNorth en = latlon2eastNorth(boxProjection.eastNorth2latlon(getPointAlong(i, n, box)));
855            if (result == null) {
856                result = new ProjectionBounds(en);
857            } else {
858                result.extend(en);
859            }
860        }
861        return result;
862    }
863
864    /**
865     * Return true, if a geographic coordinate reference system is represented.
866     *
867     * I.e. if it returns latitude/longitude values rather than Cartesian
868     * east/north coordinates on a flat surface.
869     * @return true, if it is geographic
870     * @since 12792
871     */
872    public boolean isGeographic() {
873        return proj.isGeographic();
874    }
875
876}