001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
003
004import java.text.MessageFormat;
005import java.util.ArrayList;
006import java.util.Arrays;
007import java.util.Collection;
008import java.util.Collections;
009import java.util.List;
010import java.util.Locale;
011import java.util.Objects;
012import java.util.function.IntFunction;
013
014import javax.json.JsonArray;
015import javax.json.JsonObject;
016import javax.json.JsonString;
017import javax.json.JsonValue;
018
019import org.openstreetmap.josm.data.Bounds;
020import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
021
022/**
023 * A source from a Mapbox Vector Style
024 *
025 * @author Taylor Smock
026 * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/">https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/</a>
027 * @since 17862
028 */
029public class Source {
030    /**
031     * A common function for zoom constraints
032     */
033    private static class ZoomBoundFunction implements IntFunction<Integer> {
034        private final int min;
035        private final int max;
036        /**
037         * Create a new bound for zooms
038         * @param min The min zoom
039         * @param max The max zoom
040         */
041        ZoomBoundFunction(int min, int max) {
042            this.min = min;
043            this.max = max;
044        }
045
046        @Override public Integer apply(int value) {
047            return Math.max(min, Math.min(value, max));
048        }
049    }
050
051    /**
052     * WMS servers should contain a "{bbox-epsg-3857}" parameter for the bbox
053     */
054    private static final String WMS_BBOX = "bbox-epsg-3857";
055
056    private static final String[] NO_URLS = new String[0];
057
058    /**
059     * Constrain the min/max zooms to be between 0 and 30, as per tilejson spec
060     */
061    private static final IntFunction<Integer> ZOOM_BOUND_FUNCTION = new ZoomBoundFunction(0, 30);
062
063    /* Common items */
064    /**
065     * The name of the source
066     */
067    private final String name;
068    /**
069     * The type of the source
070     */
071    private final SourceType sourceType;
072
073    /* Common tiled data */
074    /**
075     * The minimum zoom supported
076     */
077    private final int minZoom;
078    /**
079     * The maximum zoom supported
080     */
081    private final int maxZoom;
082    /**
083     * The tile urls. These usually have replaceable fields.
084     */
085    private final String[] tileUrls;
086
087    /* Vector and raster data */
088    /**
089     * The attribution to display for the user
090     */
091    private final String attribution;
092    /**
093     * The bounds of the data. We should not request data outside of the bounds
094     */
095    private final Bounds bounds;
096    /**
097     * The property to use as a feature id. Can be parameterized
098     */
099    private final String promoteId;
100    /**
101     * The tile scheme
102     */
103    private final Scheme scheme;
104    /**
105     * {@code true} if the tiles should not be cached
106     */
107    private final boolean volatileCache;
108
109    /* Raster data */
110    /**
111     * The tile size
112     */
113    private final int tileSize;
114
115    /**
116     * Create a new Source object
117     *
118     * @param name The name of the source object
119     * @param data The data to set the source information with
120     */
121    public Source(final String name, final JsonObject data) {
122        Objects.requireNonNull(name, "Name cannot be null");
123        Objects.requireNonNull(data, "Data cannot be null");
124        this.name = name;
125        // "type" is required (so throw an NPE if it doesn't exist)
126        final String type = data.getString("type");
127        this.sourceType = SourceType.valueOf(type.replace("-", "_").toUpperCase(Locale.ROOT));
128        // This can also contain SourceType.RASTER_DEM (only needs encoding)
129        if (SourceType.VECTOR == this.sourceType || SourceType.RASTER == this.sourceType) {
130            if (data.containsKey("url")) {
131                // TODO implement https://github.com/mapbox/tilejson-spec
132                throw new InvalidMapboxVectorTileException("TileJson not yet supported");
133            } else {
134                this.minZoom = ZOOM_BOUND_FUNCTION.apply(data.getInt("minzoom", 0));
135                this.maxZoom = ZOOM_BOUND_FUNCTION.apply(data.getInt("maxzoom", 22));
136                this.attribution = data.getString("attribution", null);
137                if (data.containsKey("bounds") && data.get("bounds").getValueType() == JsonValue.ValueType.ARRAY) {
138                    final JsonArray bJsonArray = data.getJsonArray("bounds");
139                    if (bJsonArray.size() != 4) {
140                        throw new IllegalArgumentException(MessageFormat.format("bounds must have four values, but has {0}", bJsonArray.size()));
141                    }
142                    final double[] bArray = new double[bJsonArray.size()];
143                    for (int i = 0; i < bJsonArray.size(); i++) {
144                        bArray[i] = bJsonArray.getJsonNumber(i).doubleValue();
145                    }
146                    // The order in the response is
147                    // [south-west longitude, south-west latitude, north-east longitude, north-east latitude]
148                    this.bounds = new Bounds(bArray[1], bArray[0], bArray[3], bArray[2]);
149                } else {
150                    // Don't use a static instance for bounds, as it is not a immutable class
151                    this.bounds = new Bounds(-85.051129, -180, 85.051129, 180);
152                }
153                this.promoteId = data.getString("promoteId", null);
154                this.scheme = Scheme.valueOf(data.getString("scheme", "xyz").toUpperCase(Locale.ROOT));
155                if (data.containsKey("tiles") && data.get("tiles").getValueType() == JsonValue.ValueType.ARRAY) {
156                    this.tileUrls = data.getJsonArray("tiles").stream().filter(JsonString.class::isInstance)
157                      .map(JsonString.class::cast).map(JsonString::getString)
158                      // Replace bbox-epsg-3857 with bbox (already encased with {})
159                      .map(url -> url.replace(WMS_BBOX, "bbox")).toArray(String[]::new);
160                } else {
161                    this.tileUrls = NO_URLS;
162                }
163                this.volatileCache = data.getBoolean("volatile", false);
164                this.tileSize = data.getInt("tileSize", 512);
165            }
166        } else {
167            throw new UnsupportedOperationException();
168        }
169    }
170
171    /**
172     * Get the bounds for this source
173     * @return The bounds where this source can be used
174     */
175    public Bounds getBounds() {
176        return this.bounds;
177    }
178
179    /**
180     * Get the source name
181     * @return the name
182     */
183    public String getName() {
184        return name;
185    }
186
187    /**
188     * Get the URLs that can be used to get vector data
189     *
190     * @return The urls
191     */
192    public List<String> getUrls() {
193        return Collections.unmodifiableList(Arrays.asList(this.tileUrls));
194    }
195
196    /**
197     * Get the minimum zoom
198     *
199     * @return The min zoom (default {@code 0})
200     */
201    public int getMinZoom() {
202        return this.minZoom;
203    }
204
205    /**
206     * Get the max zoom
207     *
208     * @return The max zoom (default {@code 22})
209     */
210    public int getMaxZoom() {
211        return this.maxZoom;
212    }
213
214    /**
215     * Get the attribution for this source
216     *
217     * @return The attribution text. May be {@code null}.
218     */
219    public String getAttributionText() {
220        return this.attribution;
221    }
222
223    @Override
224    public String toString() {
225        Collection<String> parts = new ArrayList<>(1 + this.getUrls().size());
226        parts.add(this.getName());
227        parts.addAll(this.getUrls());
228        return String.join(" ", parts);
229    }
230
231    @Override
232    public boolean equals(Object other) {
233        if (other != null && this.getClass() == other.getClass()) {
234            Source o = (Source) other;
235            return Objects.equals(this.name, o.name)
236              && this.sourceType == o.sourceType
237              && this.minZoom == o.minZoom
238              && this.maxZoom == o.maxZoom
239              && Objects.equals(this.attribution, o.attribution)
240              && Objects.equals(this.promoteId, o.promoteId)
241              && this.scheme == o.scheme
242              && this.volatileCache == o.volatileCache
243              && this.tileSize == o.tileSize
244              && Objects.equals(this.bounds, o.bounds)
245              && Objects.deepEquals(this.tileUrls, o.tileUrls);
246        }
247        return false;
248    }
249
250    @Override
251    public int hashCode() {
252        return Objects.hash(this.name, this.sourceType, this.minZoom, this.maxZoom, this.attribution, this.promoteId,
253          this.scheme, this.volatileCache, this.tileSize, this.bounds, Arrays.hashCode(this.tileUrls));
254    }
255}