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.HashMap;
008import java.util.List;
009import java.util.Map;
010import java.util.regex.Matcher;
011import java.util.regex.Pattern;
012
013import org.openstreetmap.josm.Main;
014import org.openstreetmap.josm.data.Bounds;
015import org.openstreetmap.josm.data.coor.LatLon;
016import org.openstreetmap.josm.data.projection.datum.CentricDatum;
017import org.openstreetmap.josm.data.projection.datum.Datum;
018import org.openstreetmap.josm.data.projection.datum.NTV2Datum;
019import org.openstreetmap.josm.data.projection.datum.NTV2GridShiftFileWrapper;
020import org.openstreetmap.josm.data.projection.datum.NullDatum;
021import org.openstreetmap.josm.data.projection.datum.SevenParameterDatum;
022import org.openstreetmap.josm.data.projection.datum.ThreeParameterDatum;
023import org.openstreetmap.josm.data.projection.datum.WGS84Datum;
024import org.openstreetmap.josm.data.projection.proj.Mercator;
025import org.openstreetmap.josm.data.projection.proj.Proj;
026import org.openstreetmap.josm.data.projection.proj.ProjParameters;
027import org.openstreetmap.josm.tools.Utils;
028
029/**
030 * Custom projection
031 *
032 * Inspired by PROJ.4 and Proj4J.
033 */
034public class CustomProjection extends AbstractProjection {
035
036    /**
037     * pref String that defines the projection
038     *
039     * null means fall back mode (Mercator)
040     */
041    protected String pref;
042    protected String name;
043    protected String code;
044    protected String cacheDir;
045    protected Bounds bounds;
046
047    protected static enum Param {
048
049        x_0("x_0", true),
050        y_0("y_0", true),
051        lon_0("lon_0", true),
052        k_0("k_0", true),
053        ellps("ellps", true),
054        a("a", true),
055        es("es", true),
056        rf("rf", true),
057        f("f", true),
058        b("b", true),
059        datum("datum", true),
060        towgs84("towgs84", true),
061        nadgrids("nadgrids", true),
062        proj("proj", true),
063        lat_0("lat_0", true),
064        lat_1("lat_1", true),
065        lat_2("lat_2", true),
066        wktext("wktext", false),  // ignored
067        units("units", true),     // ignored
068        no_defs("no_defs", false),
069        init("init", true),
070        // JOSM extension, not present in PROJ.4
071        bounds("bounds", true);
072
073        public String key;
074        public boolean hasValue;
075
076        public final static Map<String, Param> paramsByKey = new HashMap<String, Param>();
077        static {
078            for (Param p : Param.values()) {
079                paramsByKey.put(p.key, p);
080            }
081        }
082
083        Param(String key, boolean hasValue) {
084            this.key = key;
085            this.hasValue = hasValue;
086        }
087    }
088
089    public CustomProjection() {
090    }
091
092    public CustomProjection(String pref) {
093        this(null, null, pref, null);
094    }
095
096    /**
097     * Constructor.
098     *
099     * @param name describe projection in one or two words
100     * @param code unique code for this projection - may be null
101     * @param pref the string that defines the custom projection
102     * @param cacheDir cache directory name
103     */
104    public CustomProjection(String name, String code, String pref, String cacheDir) {
105        this.name = name;
106        this.code = code;
107        this.pref = pref;
108        this.cacheDir = cacheDir;
109        try {
110            update(pref);
111        } catch (ProjectionConfigurationException ex) {
112            try {
113                update(null);
114            } catch (ProjectionConfigurationException ex1) {
115                throw new RuntimeException();
116            }
117        }
118    }
119
120    public void update(String pref) throws ProjectionConfigurationException {
121        this.pref = pref;
122        if (pref == null) {
123            ellps = Ellipsoid.WGS84;
124            datum = WGS84Datum.INSTANCE;
125            proj = new Mercator();
126            bounds = new Bounds(
127                    -85.05112877980659, -180.0,
128                    85.05112877980659, 180.0, true);
129        } else {
130            Map<String, String> parameters = parseParameterList(pref);
131            ellps = parseEllipsoid(parameters);
132            datum = parseDatum(parameters, ellps);
133            proj = parseProjection(parameters, ellps);
134            String s = parameters.get(Param.x_0.key);
135            if (s != null) {
136                this.x_0 = parseDouble(s, Param.x_0.key);
137            }
138            s = parameters.get(Param.y_0.key);
139            if (s != null) {
140                this.y_0 = parseDouble(s, Param.y_0.key);
141            }
142            s = parameters.get(Param.lon_0.key);
143            if (s != null) {
144                this.lon_0 = parseAngle(s, Param.lon_0.key);
145            }
146            s = parameters.get(Param.k_0.key);
147            if (s != null) {
148                this.k_0 = parseDouble(s, Param.k_0.key);
149            }
150            s = parameters.get(Param.bounds.key);
151            if (s != null) {
152                this.bounds = parseBounds(s);
153            }
154        }
155    }
156
157    private Map<String, String> parseParameterList(String pref) throws ProjectionConfigurationException {
158        Map<String, String> parameters = new HashMap<String, String>();
159        String[] parts = pref.trim().split("\\s+");
160        if (pref.trim().isEmpty()) {
161            parts = new String[0];
162        }
163        for (String part : parts) {
164            if (part.isEmpty() || part.charAt(0) != '+')
165                throw new ProjectionConfigurationException(tr("Parameter must begin with a ''+'' character (found ''{0}'')", part));
166            Matcher m = Pattern.compile("\\+([a-zA-Z0-9_]+)(=(.*))?").matcher(part);
167            if (m.matches()) {
168                String key = m.group(1);
169                // alias
170                if (key.equals("k")) {
171                    key = Param.k_0.key;
172                }
173                String value = null;
174                if (m.groupCount() >= 3) {
175                    value = m.group(3);
176                    // same aliases
177                    if (key.equals(Param.proj.key)) {
178                        if (value.equals("longlat") || value.equals("latlon") || value.equals("latlong")) {
179                            value = "lonlat";
180                        }
181                    }
182                }
183                if (!Param.paramsByKey.containsKey(key))
184                    throw new ProjectionConfigurationException(tr("Unkown parameter: ''{0}''.", key));
185                if (Param.paramsByKey.get(key).hasValue && value == null)
186                    throw new ProjectionConfigurationException(tr("Value expected for parameter ''{0}''.", key));
187                if (!Param.paramsByKey.get(key).hasValue && value != null)
188                    throw new ProjectionConfigurationException(tr("No value expected for parameter ''{0}''.", key));
189                parameters.put(key, value);
190            } else
191                throw new ProjectionConfigurationException(tr("Unexpected parameter format (''{0}'')", part));
192        }
193        // recursive resolution of +init includes
194        String initKey = parameters.get(Param.init.key);
195        if (initKey != null) {
196            String init = Projections.getInit(initKey);
197            if (init == null)
198                throw new ProjectionConfigurationException(tr("Value ''{0}'' for option +init not supported.", initKey));
199            Map<String, String> initp = null;
200            try {
201                initp = parseParameterList(init);
202            } catch (ProjectionConfigurationException ex) {
203                throw new ProjectionConfigurationException(tr(initKey+": "+ex.getMessage()));
204            }
205            for (Map.Entry<String, String> e : parameters.entrySet()) {
206                initp.put(e.getKey(), e.getValue());
207            }
208            return initp;
209        }
210        return parameters;
211    }
212
213    public Ellipsoid parseEllipsoid(Map<String, String> parameters) throws ProjectionConfigurationException {
214        String code = parameters.get(Param.ellps.key);
215        if (code != null) {
216            Ellipsoid ellipsoid = Projections.getEllipsoid(code);
217            if (ellipsoid == null) {
218                throw new ProjectionConfigurationException(tr("Ellipsoid ''{0}'' not supported.", code));
219            } else {
220                return ellipsoid;
221            }
222        }
223        String s = parameters.get(Param.a.key);
224        if (s != null) {
225            double a = parseDouble(s, Param.a.key);
226            if (parameters.get(Param.es.key) != null) {
227                double es = parseDouble(parameters, Param.es.key);
228                return Ellipsoid.create_a_es(a, es);
229            }
230            if (parameters.get(Param.rf.key) != null) {
231                double rf = parseDouble(parameters, Param.rf.key);
232                return Ellipsoid.create_a_rf(a, rf);
233            }
234            if (parameters.get(Param.f.key) != null) {
235                double f = parseDouble(parameters, Param.f.key);
236                return Ellipsoid.create_a_f(a, f);
237            }
238            if (parameters.get(Param.b.key) != null) {
239                double b = parseDouble(parameters, Param.b.key);
240                return Ellipsoid.create_a_b(a, b);
241            }
242        }
243        if (parameters.containsKey(Param.a.key) ||
244                parameters.containsKey(Param.es.key) ||
245                parameters.containsKey(Param.rf.key) ||
246                parameters.containsKey(Param.f.key) ||
247                parameters.containsKey(Param.b.key))
248            throw new ProjectionConfigurationException(tr("Combination of ellipsoid parameters is not supported."));
249        if (parameters.containsKey(Param.no_defs.key))
250            throw new ProjectionConfigurationException(tr("Ellipsoid required (+ellps=* or +a=*, +b=*)"));
251        // nothing specified, use WGS84 as default
252        return Ellipsoid.WGS84;
253    }
254
255    public Datum parseDatum(Map<String, String> parameters, Ellipsoid ellps) throws ProjectionConfigurationException {
256        String nadgridsId = parameters.get(Param.nadgrids.key);
257        if (nadgridsId != null) {
258            if (nadgridsId.startsWith("@")) {
259                nadgridsId = nadgridsId.substring(1);
260            }
261            if (nadgridsId.equals("null"))
262                return new NullDatum(null, ellps);
263            NTV2GridShiftFileWrapper nadgrids = Projections.getNTV2Grid(nadgridsId);
264            if (nadgrids == null)
265                throw new ProjectionConfigurationException(tr("Grid shift file ''{0}'' for option +nadgrids not supported.", nadgridsId));
266            return new NTV2Datum(nadgridsId, null, ellps, nadgrids);
267        }
268
269        String towgs84 = parameters.get(Param.towgs84.key);
270        if (towgs84 != null)
271            return parseToWGS84(towgs84, ellps);
272
273        String datumId = parameters.get(Param.datum.key);
274        if (datumId != null) {
275            Datum datum = Projections.getDatum(datumId);
276            if (datum == null) throw new ProjectionConfigurationException(tr("Unkown datum identifier: ''{0}''", datumId));
277            return datum;
278        }
279        if (parameters.containsKey(Param.no_defs.key))
280            throw new ProjectionConfigurationException(tr("Datum required (+datum=*, +towgs84=* or +nadgrids=*)"));
281        return new CentricDatum(null, null, ellps);
282    }
283
284    public Datum parseToWGS84(String paramList, Ellipsoid ellps) throws ProjectionConfigurationException {
285        String[] numStr = paramList.split(",");
286
287        if (numStr.length != 3 && numStr.length != 7)
288            throw new ProjectionConfigurationException(tr("Unexpected number of arguments for parameter ''towgs84'' (must be 3 or 7)"));
289        List<Double> towgs84Param = new ArrayList<Double>();
290        for (String str : numStr) {
291            try {
292                towgs84Param.add(Double.parseDouble(str));
293            } catch (NumberFormatException e) {
294                throw new ProjectionConfigurationException(tr("Unable to parse value of parameter ''towgs84'' (''{0}'')", str));
295            }
296        }
297        boolean isCentric = true;
298        for (Double param : towgs84Param) {
299            if (param != 0.0) {
300                isCentric = false;
301                break;
302            }
303        }
304        if (isCentric)
305            return new CentricDatum(null, null, ellps);
306        boolean is3Param = true;
307        for (int i = 3; i<towgs84Param.size(); i++) {
308            if (towgs84Param.get(i) != 0.0) {
309                is3Param = false;
310                break;
311            }
312        }
313        if (is3Param)
314            return new ThreeParameterDatum(null, null, ellps,
315                    towgs84Param.get(0),
316                    towgs84Param.get(1),
317                    towgs84Param.get(2));
318        else
319            return new SevenParameterDatum(null, null, ellps,
320                    towgs84Param.get(0),
321                    towgs84Param.get(1),
322                    towgs84Param.get(2),
323                    towgs84Param.get(3),
324                    towgs84Param.get(4),
325                    towgs84Param.get(5),
326                    towgs84Param.get(6));
327    }
328
329    public Proj parseProjection(Map<String, String> parameters, Ellipsoid ellps) throws ProjectionConfigurationException {
330        String id = parameters.get(Param.proj.key);
331        if (id == null) throw new ProjectionConfigurationException(tr("Projection required (+proj=*)"));
332
333        Proj proj =  Projections.getBaseProjection(id);
334        if (proj == null) throw new ProjectionConfigurationException(tr("Unkown projection identifier: ''{0}''", id));
335
336        ProjParameters projParams = new ProjParameters();
337
338        projParams.ellps = ellps;
339
340        String s;
341        s = parameters.get(Param.lat_0.key);
342        if (s != null) {
343            projParams.lat_0 = parseAngle(s, Param.lat_0.key);
344        }
345        s = parameters.get(Param.lat_1.key);
346        if (s != null) {
347            projParams.lat_1 = parseAngle(s, Param.lat_1.key);
348        }
349        s = parameters.get(Param.lat_2.key);
350        if (s != null) {
351            projParams.lat_2 = parseAngle(s, Param.lat_2.key);
352        }
353        proj.initialize(projParams);
354        return proj;
355    }
356
357    public static Bounds parseBounds(String boundsStr) throws ProjectionConfigurationException {
358        String[] numStr = boundsStr.split(",");
359        if (numStr.length != 4)
360            throw new ProjectionConfigurationException(tr("Unexpected number of arguments for parameter ''+bounds'' (must be 4)"));
361        return new Bounds(parseAngle(numStr[1], "minlat (+bounds)"),
362                parseAngle(numStr[0], "minlon (+bounds)"),
363                parseAngle(numStr[3], "maxlat (+bounds)"),
364                parseAngle(numStr[2], "maxlon (+bounds)"), false);
365    }
366
367    public static double parseDouble(Map<String, String> parameters, String parameterName) throws ProjectionConfigurationException {
368        if (!parameters.containsKey(parameterName))
369            throw new IllegalArgumentException(tr("Unknown parameter ''{0}''", parameterName));
370        String doubleStr = parameters.get(parameterName);
371        if (doubleStr == null)
372            throw new ProjectionConfigurationException(
373                    tr("Expected number argument for parameter ''{0}''", parameterName));
374        return parseDouble(doubleStr, parameterName);
375    }
376
377    public static double parseDouble(String doubleStr, String parameterName) throws ProjectionConfigurationException {
378        try {
379            return Double.parseDouble(doubleStr);
380        } catch (NumberFormatException e) {
381            throw new ProjectionConfigurationException(
382                    tr("Unable to parse value ''{1}'' of parameter ''{0}'' as number.", parameterName, doubleStr));
383        }
384    }
385
386    public static double parseAngle(String angleStr, String parameterName) throws ProjectionConfigurationException {
387        String s = angleStr;
388        double value = 0;
389        boolean neg = false;
390        Matcher m = Pattern.compile("^-").matcher(s);
391        if (m.find()) {
392            neg = true;
393            s = s.substring(m.end());
394        }
395        final String FLOAT = "(\\d+(\\.\\d*)?)";
396        boolean dms = false;
397        double deg = 0.0, min = 0.0, sec = 0.0;
398        // degrees
399        m = Pattern.compile("^"+FLOAT+"d").matcher(s);
400        if (m.find()) {
401            s = s.substring(m.end());
402            deg = Double.parseDouble(m.group(1));
403            dms = true;
404        }
405        // minutes
406        m = Pattern.compile("^"+FLOAT+"'").matcher(s);
407        if (m.find()) {
408            s = s.substring(m.end());
409            min = Double.parseDouble(m.group(1));
410            dms = true;
411        }
412        // seconds
413        m = Pattern.compile("^"+FLOAT+"\"").matcher(s);
414        if (m.find()) {
415            s = s.substring(m.end());
416            sec = Double.parseDouble(m.group(1));
417            dms = true;
418        }
419        // plain number (in degrees)
420        if (dms) {
421            value = deg + (min/60.0) + (sec/3600.0);
422        } else {
423            m = Pattern.compile("^"+FLOAT).matcher(s);
424            if (m.find()) {
425                s = s.substring(m.end());
426                value += Double.parseDouble(m.group(1));
427            }
428        }
429        m = Pattern.compile("^(N|E)", Pattern.CASE_INSENSITIVE).matcher(s);
430        if (m.find()) {
431            s = s.substring(m.end());
432        } else {
433            m = Pattern.compile("^(S|W)", Pattern.CASE_INSENSITIVE).matcher(s);
434            if (m.find()) {
435                s = s.substring(m.end());
436                neg = !neg;
437            }
438        }
439        if (neg) {
440            value = -value;
441        }
442        if (!s.isEmpty()) {
443            throw new ProjectionConfigurationException(
444                    tr("Unable to parse value ''{1}'' of parameter ''{0}'' as coordinate value.", parameterName, angleStr));
445        }
446        return value;
447    }
448
449    @Override
450    public Integer getEpsgCode() {
451        if (code != null && code.startsWith("EPSG:")) {
452            try {
453                return Integer.parseInt(code.substring(5));
454            } catch (NumberFormatException e) {
455                Main.warn(e);
456            }
457        }
458        return null;
459    }
460
461    @Override
462    public String toCode() {
463        return code != null ? code : "proj:" + (pref == null ? "ERROR" : pref);
464    }
465
466    @Override
467    public String getCacheDirectoryName() {
468        return cacheDir != null ? cacheDir : "proj-"+Utils.md5Hex(pref == null ? "" : pref).substring(0, 4);
469    }
470
471    @Override
472    public Bounds getWorldBoundsLatLon() {
473        if (bounds != null) return bounds;
474        return new Bounds(
475            new LatLon(-90.0, -180.0),
476            new LatLon(90.0, 180.0));
477    }
478
479    @Override
480    public String toString() {
481        return name != null ? name : tr("Custom Projection");
482    }
483}