source: josm/trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java@ 17862

Last change on this file since 17862 was 17862, checked in by simon04, 3 years ago

fix #17177 - Add support for Mapbox Vector Tile (patch by taylor.smock)

Signed-off-by: Taylor Smock <tsmock@…>

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