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