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