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}