001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io;
003
004import java.io.StringReader;
005import java.io.StringWriter;
006import java.io.Writer;
007import java.math.BigDecimal;
008import java.math.RoundingMode;
009import java.util.Collection;
010import java.util.Collections;
011import java.util.HashSet;
012import java.util.Iterator;
013import java.util.List;
014import java.util.Map;
015import java.util.Map.Entry;
016import java.util.Set;
017import java.util.stream.Stream;
018
019import javax.json.Json;
020import javax.json.JsonArrayBuilder;
021import javax.json.JsonObject;
022import javax.json.JsonObjectBuilder;
023import javax.json.JsonValue;
024import javax.json.JsonWriter;
025import javax.json.stream.JsonGenerator;
026import javax.json.stream.JsonParser;
027import javax.json.stream.JsonParsingException;
028
029import org.openstreetmap.josm.data.Bounds;
030import org.openstreetmap.josm.data.coor.EastNorth;
031import org.openstreetmap.josm.data.coor.LatLon;
032import org.openstreetmap.josm.data.osm.DataSet;
033import org.openstreetmap.josm.data.osm.MultipolygonBuilder;
034import org.openstreetmap.josm.data.osm.MultipolygonBuilder.JoinedPolygon;
035import org.openstreetmap.josm.data.osm.Node;
036import org.openstreetmap.josm.data.osm.OsmPrimitive;
037import org.openstreetmap.josm.data.osm.Relation;
038import org.openstreetmap.josm.data.osm.Way;
039import org.openstreetmap.josm.data.osm.visitor.OsmPrimitiveVisitor;
040import org.openstreetmap.josm.data.preferences.BooleanProperty;
041import org.openstreetmap.josm.data.projection.Projection;
042import org.openstreetmap.josm.data.projection.Projections;
043import org.openstreetmap.josm.gui.mappaint.ElemStyles;
044import org.openstreetmap.josm.tools.Logging;
045import org.openstreetmap.josm.tools.Pair;
046
047/**
048 * Writes OSM data as a GeoJSON string, using JSR 353: Java API for JSON Processing (JSON-P).
049 * <p>
050 * See <a href="https://tools.ietf.org/html/rfc7946">RFC7946: The GeoJSON Format</a>
051 */
052public class GeoJSONWriter {
053
054    private final DataSet data;
055    private final Projection projection;
056    private static final BooleanProperty SKIP_EMPTY_NODES = new BooleanProperty("geojson.export.skip-empty-nodes", true);
057    private static final BooleanProperty UNTAGGED_CLOSED_IS_POLYGON = new BooleanProperty("geojson.export.untagged-closed-is-polygon", false);
058    private static final Set<Way> processedMultipolygonWays = new HashSet<>();
059
060    /**
061     * This is used to determine that a tag should be interpreted as a json
062     * object or array. The tag should have both {@link #JSON_VALUE_START_MARKER}
063     * and {@link #JSON_VALUE_END_MARKER}.
064     */
065    static final String JSON_VALUE_START_MARKER = "{";
066    /**
067     * This is used to determine that a tag should be interpreted as a json
068     * object or array. The tag should have both {@link #JSON_VALUE_START_MARKER}
069     * and {@link #JSON_VALUE_END_MARKER}.
070     */
071    static final String JSON_VALUE_END_MARKER = "}";
072
073    /**
074     * Constructs a new {@code GeoJSONWriter}.
075     * @param ds The OSM data set to save
076     * @since 12806
077     */
078    public GeoJSONWriter(DataSet ds) {
079        this.data = ds;
080        this.projection = Projections.getProjectionByCode("EPSG:4326"); // WGS 84
081    }
082
083    /**
084     * Writes OSM data as a GeoJSON string (prettified).
085     * @return The GeoJSON data
086     */
087    public String write() {
088        return write(true);
089    }
090
091    /**
092     * Writes OSM data as a GeoJSON string (prettified or not).
093     * @param pretty {@code true} to have pretty output, {@code false} otherwise
094     * @return The GeoJSON data
095     * @since 6756
096     */
097    public String write(boolean pretty) {
098        StringWriter stringWriter = new StringWriter();
099        write(pretty, stringWriter);
100        return stringWriter.toString();
101    }
102
103    /**
104     * Writes OSM data as a GeoJSON string (prettified or not).
105     * @param pretty {@code true} to have pretty output, {@code false} otherwise
106     * @param writer The writer used to write results
107     */
108    public void write(boolean pretty, Writer writer) {
109        Map<String, Object> config = Collections.singletonMap(JsonGenerator.PRETTY_PRINTING, pretty);
110        try (JsonWriter jsonWriter = Json.createWriterFactory(config).createWriter(writer)) {
111            JsonObjectBuilder object = Json.createObjectBuilder()
112                    .add("type", "FeatureCollection")
113                    .add("generator", "JOSM");
114            appendLayerBounds(data, object);
115            appendLayerFeatures(data, object);
116            jsonWriter.writeObject(object.build());
117        }
118    }
119
120    private class GeometryPrimitiveVisitor implements OsmPrimitiveVisitor {
121
122        private final JsonObjectBuilder geomObj;
123
124        GeometryPrimitiveVisitor(JsonObjectBuilder geomObj) {
125            this.geomObj = geomObj;
126        }
127
128        @Override
129        public void visit(Node n) {
130            geomObj.add("type", "Point");
131            LatLon ll = n.getCoor();
132            if (ll != null) {
133                geomObj.add("coordinates", getCoorArray(null, ll));
134            }
135        }
136
137        @Override
138        public void visit(Way w) {
139            if (w != null) {
140                if (!w.isTagged() && processedMultipolygonWays.contains(w)) {
141                    // no need to write this object again
142                    return;
143                }
144                final JsonArrayBuilder array = getCoorsArray(w.getNodes());
145                boolean writeAsPolygon = w.isClosed() && ((!w.isTagged() && UNTAGGED_CLOSED_IS_POLYGON.get())
146                        || ElemStyles.hasAreaElemStyle(w, false));
147                if (writeAsPolygon) {
148                    geomObj.add("type", "Polygon");
149                    geomObj.add("coordinates", Json.createArrayBuilder().add(array));
150                } else {
151                    geomObj.add("type", "LineString");
152                    geomObj.add("coordinates", array);
153                }
154            }
155        }
156
157        @Override
158        public void visit(Relation r) {
159            if (r == null || !r.isMultipolygon() || r.hasIncompleteMembers()) {
160                return;
161            }
162            try {
163                final Pair<List<JoinedPolygon>, List<JoinedPolygon>> mp = MultipolygonBuilder.joinWays(r);
164                final JsonArrayBuilder polygon = Json.createArrayBuilder();
165                Stream.concat(mp.a.stream(), mp.b.stream())
166                        .map(p -> {
167                            JsonArrayBuilder array = getCoorsArray(p.getNodes());
168                            LatLon ll = p.getNodes().get(0).getCoor();
169                            // since first node is not duplicated as last node
170                            return ll != null ? array.add(getCoorArray(null, ll)) : array;
171                            })
172                        .forEach(polygon::add);
173                geomObj.add("type", "MultiPolygon");
174                final JsonArrayBuilder multiPolygon = Json.createArrayBuilder().add(polygon);
175                geomObj.add("coordinates", multiPolygon);
176                processedMultipolygonWays.addAll(r.getMemberPrimitives(Way.class));
177            } catch (MultipolygonBuilder.JoinedPolygonCreationException ex) {
178                Logging.warn("GeoJSON: Failed to export multipolygon {0}", r.getUniqueId());
179                Logging.warn(ex);
180            }
181        }
182
183        private JsonArrayBuilder getCoorsArray(Iterable<Node> nodes) {
184            final JsonArrayBuilder builder = Json.createArrayBuilder();
185            for (Node n : nodes) {
186                LatLon ll = n.getCoor();
187                if (ll != null) {
188                    builder.add(getCoorArray(null, ll));
189                }
190            }
191            return builder;
192        }
193    }
194
195    private JsonArrayBuilder getCoorArray(JsonArrayBuilder builder, LatLon c) {
196        return getCoorArray(builder, projection.latlon2eastNorth(c));
197    }
198
199    private static JsonArrayBuilder getCoorArray(JsonArrayBuilder builder, EastNorth c) {
200        return (builder != null ? builder : Json.createArrayBuilder())
201                .add(BigDecimal.valueOf(c.getX()).setScale(11, RoundingMode.HALF_UP))
202                .add(BigDecimal.valueOf(c.getY()).setScale(11, RoundingMode.HALF_UP));
203    }
204
205    protected void appendPrimitive(OsmPrimitive p, JsonArrayBuilder array) {
206        if (p.isIncomplete() ||
207            (SKIP_EMPTY_NODES.get() && p instanceof Node && p.getKeys().isEmpty())) {
208            return;
209        }
210
211        // Properties
212        final JsonObjectBuilder propObj = Json.createObjectBuilder();
213        for (Entry<String, String> t : p.getKeys().entrySet()) {
214            propObj.add(t.getKey(), convertValueToJson(t.getValue()));
215        }
216        final JsonObject prop = propObj.build();
217
218        // Geometry
219        final JsonObjectBuilder geomObj = Json.createObjectBuilder();
220        p.accept(new GeometryPrimitiveVisitor(geomObj));
221        final JsonObject geom = geomObj.build();
222
223        if (!geom.isEmpty()) {
224            // Build primitive JSON object
225            array.add(Json.createObjectBuilder()
226                    .add("type", "Feature")
227                    .add("properties", prop.isEmpty() ? JsonValue.NULL : prop)
228                    .add("geometry", geom.isEmpty() ? JsonValue.NULL : geom));
229        }
230    }
231
232    private static JsonValue convertValueToJson(String value) {
233        if (value.startsWith(JSON_VALUE_START_MARKER) && value.endsWith(JSON_VALUE_END_MARKER)) {
234            try (JsonParser parser = Json.createParser(new StringReader(value))) {
235                if (parser.hasNext() && parser.next() != null) {
236                    return parser.getValue();
237                }
238            } catch (JsonParsingException e) {
239                Logging.warn(e);
240            }
241        }
242        return Json.createValue(value);
243    }
244
245    protected void appendLayerBounds(DataSet ds, JsonObjectBuilder object) {
246        if (ds != null) {
247            Iterator<Bounds> it = ds.getDataSourceBounds().iterator();
248            if (it.hasNext()) {
249                Bounds b = new Bounds(it.next());
250                while (it.hasNext()) {
251                    b.extend(it.next());
252                }
253                appendBounds(b, object);
254            }
255        }
256    }
257
258    protected void appendBounds(Bounds b, JsonObjectBuilder object) {
259        if (b != null) {
260            JsonArrayBuilder builder = Json.createArrayBuilder();
261            getCoorArray(builder, b.getMin());
262            getCoorArray(builder, b.getMax());
263            object.add("bbox", builder);
264        }
265    }
266
267    protected void appendLayerFeatures(DataSet ds, JsonObjectBuilder object) {
268        JsonArrayBuilder array = Json.createArrayBuilder();
269        if (ds != null) {
270            processedMultipolygonWays.clear();
271            Collection<OsmPrimitive> primitives = ds.allNonDeletedPrimitives();
272            // Relations first
273            for (OsmPrimitive p : primitives) {
274                if (p instanceof Relation)
275                    appendPrimitive(p, array);
276            }
277            for (OsmPrimitive p : primitives) {
278                if (!(p instanceof Relation))
279                    appendPrimitive(p, array);
280            }
281            processedMultipolygonWays.clear();
282        }
283        object.add("features", array);
284    }
285}