001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style; 003 004import java.awt.Font; 005import java.awt.GraphicsEnvironment; 006import java.text.MessageFormat; 007import java.util.Arrays; 008import java.util.Collection; 009import java.util.List; 010import java.util.Locale; 011import java.util.Objects; 012import java.util.regex.Matcher; 013import java.util.regex.Pattern; 014import java.util.stream.Collectors; 015import java.util.stream.Stream; 016 017import javax.json.JsonArray; 018import javax.json.JsonNumber; 019import javax.json.JsonObject; 020import javax.json.JsonString; 021import javax.json.JsonValue; 022 023import org.openstreetmap.josm.gui.mappaint.StyleKeys; 024import org.openstreetmap.josm.tools.Utils; 025 026/** 027 * Mapbox style layers 028 * @author Taylor Smock 029 * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/">https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/</a> 030 * @since 17862 031 */ 032public class Layers { 033 /** 034 * The layer type. This affects the rendering. 035 * @author Taylor Smock 036 * @since 17862 037 */ 038 enum Type { 039 /** Filled polygon with an (optional) border */ 040 FILL, 041 /** A line */ 042 LINE, 043 /** A symbol */ 044 SYMBOL, 045 /** A circle */ 046 CIRCLE, 047 /** A heatmap */ 048 HEATMAP, 049 /** A 3D polygon extrusion */ 050 FILL_EXTRUSION, 051 /** Raster */ 052 RASTER, 053 /** Hillshade data */ 054 HILLSHADE, 055 /** A background color or pattern */ 056 BACKGROUND, 057 /** The fallback layer */ 058 SKY 059 } 060 061 private static final String EMPTY_STRING = ""; 062 private static final char SEMI_COLON = ';'; 063 private static final Pattern CURLY_BRACES = Pattern.compile("(\\{(.*?)})"); 064 private static final String PAINT = "paint"; 065 066 /** A required unique layer name */ 067 private final String id; 068 /** The required type */ 069 private final Type type; 070 /** An optional expression */ 071 private final Expression filter; 072 /** The max zoom for the layer */ 073 private final int maxZoom; 074 /** The min zoom for the layer */ 075 private final int minZoom; 076 077 /** Default paint properties for this layer */ 078 private final String paint; 079 080 /** A source description to be used with this layer. Required for everything <i>but</i> {@link Type#BACKGROUND} */ 081 private final String source; 082 /** Layer to use from the vector tile source. Only allowed with {@link SourceType#VECTOR}. */ 083 private final String sourceLayer; 084 /** The id for the style -- used for image paths */ 085 private final String styleId; 086 /** 087 * Create a layer object 088 * @param layerInfo The info to use to create the layer 089 */ 090 public Layers(final JsonObject layerInfo) { 091 this (null, layerInfo); 092 } 093 094 /** 095 * Create a layer object 096 * @param styleId The id for the style (image paths require this) 097 * @param layerInfo The info to use to create the layer 098 */ 099 public Layers(final String styleId, final JsonObject layerInfo) { 100 this.id = layerInfo.getString("id"); 101 this.styleId = styleId; 102 this.type = Type.valueOf(layerInfo.getString("type").replace("-", "_").toUpperCase(Locale.ROOT)); 103 if (layerInfo.containsKey("filter")) { 104 this.filter = new Expression(layerInfo.get("filter")); 105 } else { 106 this.filter = Expression.EMPTY_EXPRESSION; 107 } 108 this.maxZoom = layerInfo.getInt("maxzoom", Integer.MAX_VALUE); 109 this.minZoom = layerInfo.getInt("minzoom", Integer.MIN_VALUE); 110 // There is a metadata field (I don't *think* I need it?) 111 // source is only optional with {@link Type#BACKGROUND}. 112 if (this.type == Type.BACKGROUND) { 113 this.source = layerInfo.getString("source", null); 114 } else { 115 this.source = layerInfo.getString("source"); 116 } 117 if (layerInfo.containsKey(PAINT) && layerInfo.get(PAINT).getValueType() == JsonValue.ValueType.OBJECT) { 118 final JsonObject paintObject = layerInfo.getJsonObject(PAINT); 119 final JsonObject layoutObject = layerInfo.getOrDefault("layout", JsonValue.EMPTY_JSON_OBJECT).asJsonObject(); 120 // Don't throw exceptions here, since we may just point at the styling 121 if ("visible".equalsIgnoreCase(layoutObject.getString("visibility", "visible"))) { 122 switch (type) { 123 case FILL: 124 // area 125 this.paint = parsePaintFill(paintObject); 126 break; 127 case LINE: 128 // way 129 this.paint = parsePaintLine(layoutObject, paintObject); 130 break; 131 case CIRCLE: 132 // point 133 this.paint = parsePaintCircle(paintObject); 134 break; 135 case SYMBOL: 136 // point 137 this.paint = parsePaintSymbol(layoutObject, paintObject); 138 break; 139 case BACKGROUND: 140 // canvas only 141 this.paint = parsePaintBackground(paintObject); 142 break; 143 default: 144 this.paint = EMPTY_STRING; 145 } 146 } else { 147 this.paint = EMPTY_STRING; 148 } 149 } else { 150 this.paint = EMPTY_STRING; 151 } 152 this.sourceLayer = layerInfo.getString("source-layer", null); 153 } 154 155 /** 156 * Get the filter for this layer 157 * @return The filter 158 */ 159 public Expression getFilter() { 160 return this.filter; 161 } 162 163 /** 164 * Get the unique id for this layer 165 * @return The unique id 166 */ 167 public String getId() { 168 return this.id; 169 } 170 171 /** 172 * Get the type of this layer 173 * @return The layer type 174 */ 175 public Type getType() { 176 return this.type; 177 } 178 179 private static String parsePaintLine(final JsonObject layoutObject, final JsonObject paintObject) { 180 final StringBuilder sb = new StringBuilder(36); 181 // line-blur, default 0 (px) 182 // line-color, default #000000, disabled by line-pattern 183 final String color = paintObject.getString("line-color", "#000000"); 184 sb.append(StyleKeys.COLOR).append(':').append(color).append(SEMI_COLON); 185 // line-opacity, default 1 (0-1) 186 final JsonNumber opacity = paintObject.getJsonNumber("line-opacity"); 187 if (opacity != null) { 188 sb.append(StyleKeys.OPACITY).append(':').append(opacity.numberValue().doubleValue()).append(SEMI_COLON); 189 } 190 // line-cap, default butt (butt|round|square) 191 final String cap = layoutObject.getString("line-cap", "butt"); 192 sb.append(StyleKeys.LINECAP).append(':'); 193 switch (cap) { 194 case "round": 195 case "square": 196 sb.append(cap); 197 break; 198 case "butt": 199 default: 200 sb.append("none"); 201 } 202 203 sb.append(SEMI_COLON); 204 // line-dasharray, array of number >= 0, units in line widths, disabled by line-pattern 205 if (paintObject.containsKey("line-dasharray")) { 206 final JsonArray dashArray = paintObject.getJsonArray("line-dasharray"); 207 sb.append(StyleKeys.DASHES).append(':'); 208 sb.append(dashArray.stream().filter(JsonNumber.class::isInstance).map(JsonNumber.class::cast) 209 .map(JsonNumber::toString).collect(Collectors.joining(","))); 210 sb.append(SEMI_COLON); 211 } 212 // line-gap-width 213 // line-gradient 214 // line-join 215 // line-miter-limit 216 // line-offset 217 // line-pattern TODO this first, since it disables stuff 218 // line-round-limit 219 // line-sort-key 220 // line-translate 221 // line-translate-anchor 222 // line-width 223 final JsonNumber width = paintObject.getJsonNumber("line-width"); 224 sb.append(StyleKeys.WIDTH).append(':').append(width == null ? 1 : width.toString()).append(SEMI_COLON); 225 return sb.toString(); 226 } 227 228 private static String parsePaintCircle(final JsonObject paintObject) { 229 final StringBuilder sb = new StringBuilder(150).append("symbol-shape:circle;") 230 // circle-blur 231 // circle-color 232 .append("symbol-fill-color:").append(paintObject.getString("circle-color", "#000000")).append(SEMI_COLON); 233 // circle-opacity 234 final JsonNumber fillOpacity = paintObject.getJsonNumber("circle-opacity"); 235 sb.append("symbol-fill-opacity:").append(fillOpacity != null ? fillOpacity.numberValue().toString() : "1").append(SEMI_COLON); 236 // circle-pitch-alignment // not 3D 237 // circle-pitch-scale // not 3D 238 // circle-radius 239 final JsonNumber radius = paintObject.getJsonNumber("circle-radius"); 240 sb.append("symbol-size:").append(radius != null ? (2 * radius.numberValue().doubleValue()) : "10").append(SEMI_COLON) 241 // circle-sort-key 242 // circle-stroke-color 243 .append("symbol-stroke-color:").append(paintObject.getString("circle-stroke-color", "#000000")).append(SEMI_COLON); 244 // circle-stroke-opacity 245 final JsonNumber strokeOpacity = paintObject.getJsonNumber("circle-stroke-opacity"); 246 sb.append("symbol-stroke-opacity:").append(strokeOpacity != null ? strokeOpacity.numberValue().toString() : "1").append(SEMI_COLON); 247 // circle-stroke-width 248 final JsonNumber strokeWidth = paintObject.getJsonNumber("circle-stroke-width"); 249 sb.append("symbol-stroke-width:").append(strokeWidth != null ? strokeWidth.numberValue().toString() : "0").append(SEMI_COLON); 250 // circle-translate 251 // circle-translate-anchor 252 return sb.toString(); 253 } 254 255 private String parsePaintSymbol( 256 final JsonObject layoutObject, 257 final JsonObject paintObject) { 258 final StringBuilder sb = new StringBuilder(); 259 // icon-allow-overlap 260 // icon-anchor 261 // icon-color 262 // icon-halo-blur 263 // icon-halo-color 264 // icon-halo-width 265 // icon-ignore-placement 266 // icon-image 267 boolean iconImage = false; 268 if (layoutObject.containsKey("icon-image")) { 269 sb.append(/* NO-ICON */"icon-image:concat("); 270 if (!Utils.isBlank(this.styleId)) { 271 sb.append('"').append(this.styleId).append('/').append("\","); 272 } 273 Matcher matcher = CURLY_BRACES.matcher(layoutObject.getString("icon-image")); 274 StringBuffer stringBuffer = new StringBuffer(); 275 int previousMatch; 276 if (matcher.lookingAt()) { 277 matcher.appendReplacement(stringBuffer, "tag(\"$2\"),\""); 278 previousMatch = matcher.end(); 279 } else { 280 previousMatch = 0; 281 stringBuffer.append('"'); 282 } 283 while (matcher.find()) { 284 if (matcher.start() == previousMatch) { 285 matcher.appendReplacement(stringBuffer, ",tag(\"$2\")"); 286 } else { 287 matcher.appendReplacement(stringBuffer, "\",tag(\"$2\"),\""); 288 } 289 previousMatch = matcher.end(); 290 } 291 if (matcher.hitEnd() && stringBuffer.toString().endsWith(",\"")) { 292 stringBuffer.delete(stringBuffer.length() - ",\"".length(), stringBuffer.length()); 293 } else if (!matcher.hitEnd()) { 294 stringBuffer.append('"'); 295 } 296 StringBuffer tail = new StringBuffer(); 297 matcher.appendTail(tail); 298 if (tail.length() > 0) { 299 String current = stringBuffer.toString(); 300 if (!"\"".equals(current) && !current.endsWith(",\"")) { 301 stringBuffer.append(",\""); 302 } 303 stringBuffer.append(tail); 304 stringBuffer.append('"'); 305 } 306 307 sb.append(stringBuffer).append(')').append(SEMI_COLON); 308 iconImage = true; 309 } 310 // icon-keep-upright 311 // icon-offset 312 if (iconImage && layoutObject.containsKey("icon-offset")) { 313 // default [0, 0], right,down == positive, left,up == negative 314 final List<JsonNumber> offset = layoutObject.getJsonArray("icon-offset").getValuesAs(JsonNumber.class); 315 // Assume that the offset must be size 2. Probably not necessary, but docs aren't necessary clear. 316 if (offset.size() == 2) { 317 sb.append("icon-offset-x:").append(offset.get(0).doubleValue()).append(SEMI_COLON) 318 .append("icon-offset-y:").append(offset.get(1).doubleValue()).append(SEMI_COLON); 319 } 320 } 321 // icon-opacity 322 if (iconImage && paintObject.containsKey("icon-opacity")) { 323 final double opacity = paintObject.getJsonNumber("icon-opacity").doubleValue(); 324 sb.append("icon-opacity:").append(opacity).append(SEMI_COLON); 325 } 326 // icon-optional 327 // icon-padding 328 // icon-pitch-alignment 329 // icon-rotate 330 if (iconImage && layoutObject.containsKey("icon-rotate")) { 331 final double rotation = layoutObject.getJsonNumber("icon-rotate").doubleValue(); 332 sb.append("icon-rotation:").append(rotation).append(SEMI_COLON); 333 } 334 // icon-rotation-alignment 335 // icon-size 336 // icon-text-fit 337 // icon-text-fit-padding 338 // icon-translate 339 // icon-translate-anchor 340 // symbol-avoid-edges 341 // symbol-placement 342 // symbol-sort-key 343 // symbol-spacing 344 // symbol-z-order 345 // text-allow-overlap 346 // text-anchor 347 // text-color 348 if (paintObject.containsKey(StyleKeys.TEXT_COLOR)) { 349 sb.append(StyleKeys.TEXT_COLOR).append(':').append(paintObject.getString(StyleKeys.TEXT_COLOR)).append(SEMI_COLON); 350 } 351 // text-field 352 if (layoutObject.containsKey("text-field")) { 353 sb.append(StyleKeys.TEXT).append(':') 354 .append(layoutObject.getString("text-field").replace("}", EMPTY_STRING).replace("{", EMPTY_STRING)) 355 .append(SEMI_COLON); 356 } 357 // text-font 358 if (layoutObject.containsKey("text-font")) { 359 List<String> fonts = layoutObject.getJsonArray("text-font").stream().filter(JsonString.class::isInstance) 360 .map(JsonString.class::cast).map(JsonString::getString).collect(Collectors.toList()); 361 Font[] systemFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAllFonts(); 362 for (String fontString : fonts) { 363 Collection<Font> fontMatches = Stream.of(systemFonts) 364 .filter(font -> Arrays.asList(font.getName(), font.getFontName(), font.getFamily(), font.getPSName()).contains(fontString)) 365 .collect(Collectors.toList()); 366 if (!fontMatches.isEmpty()) { 367 final Font setFont = fontMatches.stream().filter(font -> font.getName().equals(fontString)).findAny() 368 .orElseGet(() -> fontMatches.stream().filter(font -> font.getFontName().equals(fontString)).findAny() 369 .orElseGet(() -> fontMatches.stream().filter(font -> font.getPSName().equals(fontString)).findAny() 370 .orElseGet(() -> fontMatches.stream().filter(font -> font.getFamily().equals(fontString)).findAny().orElse(null)))); 371 if (setFont != null) { 372 sb.append(StyleKeys.FONT_FAMILY).append(':').append('"').append(setFont.getFamily()).append('"').append(SEMI_COLON); 373 sb.append(StyleKeys.FONT_WEIGHT).append(':').append(setFont.isBold() ? "bold" : "normal").append(SEMI_COLON); 374 sb.append(StyleKeys.FONT_STYLE).append(':').append(setFont.isItalic() ? "italic" : "normal").append(SEMI_COLON); 375 break; 376 } 377 } 378 } 379 } 380 // text-halo-blur 381 // text-halo-color 382 if (paintObject.containsKey(StyleKeys.TEXT_HALO_COLOR)) { 383 sb.append(StyleKeys.TEXT_HALO_COLOR).append(':').append(paintObject.getString(StyleKeys.TEXT_HALO_COLOR)).append(SEMI_COLON); 384 } 385 // text-halo-width 386 if (paintObject.containsKey("text-halo-width")) { 387 sb.append(StyleKeys.TEXT_HALO_RADIUS).append(':').append(paintObject.getJsonNumber("text-halo-width").intValue() / 2) 388 .append(SEMI_COLON); 389 } 390 // text-ignore-placement 391 // text-justify 392 // text-keep-upright 393 // text-letter-spacing 394 // text-line-height 395 // text-max-angle 396 // text-max-width 397 // text-offset 398 // text-opacity 399 if (paintObject.containsKey(StyleKeys.TEXT_OPACITY)) { 400 sb.append(StyleKeys.TEXT_OPACITY).append(':').append(paintObject.getJsonNumber(StyleKeys.TEXT_OPACITY).doubleValue()) 401 .append(SEMI_COLON); 402 } 403 // text-optional 404 // text-padding 405 // text-pitch-alignment 406 // text-radial-offset 407 // text-rotate 408 // text-rotation-alignment 409 // text-size 410 final JsonNumber textSize = layoutObject.getJsonNumber("text-size"); 411 sb.append(StyleKeys.FONT_SIZE).append(':').append(textSize != null ? textSize.numberValue().toString() : "16").append(SEMI_COLON); 412 // text-transform 413 // text-translate 414 // text-translate-anchor 415 // text-variable-anchor 416 // text-writing-mode 417 return sb.toString(); 418 } 419 420 private static String parsePaintBackground(final JsonObject paintObject) { 421 final StringBuilder sb = new StringBuilder(20); 422 // background-color 423 final String bgColor = paintObject.getString("background-color", null); 424 if (bgColor != null) { 425 sb.append(StyleKeys.FILL_COLOR).append(':').append(bgColor).append(SEMI_COLON); 426 } 427 // background-opacity 428 // background-pattern 429 return sb.toString(); 430 } 431 432 private static String parsePaintFill(final JsonObject paintObject) { 433 StringBuilder sb = new StringBuilder(50) 434 // fill-antialias 435 // fill-color 436 .append(StyleKeys.FILL_COLOR).append(':').append(paintObject.getString(StyleKeys.FILL_COLOR, "#000000")).append(SEMI_COLON); 437 // fill-opacity 438 final JsonNumber opacity = paintObject.getJsonNumber(StyleKeys.FILL_OPACITY); 439 sb.append(StyleKeys.FILL_OPACITY).append(':').append(opacity != null ? opacity.numberValue().toString() : "1").append(SEMI_COLON) 440 // fill-outline-color 441 .append(StyleKeys.COLOR).append(':').append(paintObject.getString("fill-outline-color", 442 paintObject.getString("fill-color", "#000000"))).append(SEMI_COLON); 443 // fill-pattern 444 // fill-sort-key 445 // fill-translate 446 // fill-translate-anchor 447 return sb.toString(); 448 } 449 450 /** 451 * Converts this layer object to a mapcss entry string (to be parsed later) 452 * @return The mapcss entry (string form) 453 */ 454 @Override 455 public String toString() { 456 if (this.filter.toString().isEmpty() && this.paint.isEmpty()) { 457 return EMPTY_STRING; 458 } else if (this.type == Type.BACKGROUND) { 459 // AFAIK, paint has no zoom levels, and doesn't accept a layer 460 return "canvas{" + this.paint + "}"; 461 } 462 463 final String zoomSelector; 464 if (this.minZoom == this.maxZoom) { 465 zoomSelector = "|z" + this.minZoom; 466 } else if (this.minZoom > Integer.MIN_VALUE && this.maxZoom == Integer.MAX_VALUE) { 467 zoomSelector = "|z" + this.minZoom + "-"; 468 } else if (this.minZoom == Integer.MIN_VALUE && this.maxZoom < Integer.MAX_VALUE) { 469 zoomSelector = "|z-" + this.maxZoom; 470 } else if (this.minZoom > Integer.MIN_VALUE) { 471 zoomSelector = MessageFormat.format("|z{0}-{1}", this.minZoom, this.maxZoom); 472 } else { 473 zoomSelector = EMPTY_STRING; 474 } 475 final String commonData = zoomSelector + this.filter.toString() + "::" + this.id + "{" + this.paint + "}"; 476 477 if (this.type == Type.CIRCLE || this.type == Type.SYMBOL) { 478 return "node" + commonData; 479 } else if (this.type == Type.FILL) { 480 return "area" + commonData; 481 } else if (this.type == Type.LINE) { 482 return "way" + commonData; 483 } 484 return super.toString(); 485 } 486 487 /** 488 * Get the source that this applies to 489 * @return The source name 490 */ 491 public String getSource() { 492 return this.source; 493 } 494 495 /** 496 * Get the layer that this applies to 497 * @return The layer name 498 */ 499 public String getSourceLayer() { 500 return this.sourceLayer; 501 } 502 503 @Override 504 public boolean equals(Object other) { 505 if (other != null && this.getClass() == other.getClass()) { 506 Layers o = (Layers) other; 507 return this.type == o.type 508 && this.minZoom == o.minZoom 509 && this.maxZoom == o.maxZoom 510 && Objects.equals(this.id, o.id) 511 && Objects.equals(this.styleId, o.styleId) 512 && Objects.equals(this.sourceLayer, o.sourceLayer) 513 && Objects.equals(this.source, o.source) 514 && Objects.equals(this.filter, o.filter) 515 && Objects.equals(this.paint, o.paint); 516 } 517 return false; 518 } 519 520 @Override 521 public int hashCode() { 522 return Objects.hash(this.type, this.minZoom, this.maxZoom, this.id, this.styleId, this.sourceLayer, this.source, 523 this.filter, this.paint); 524 } 525}