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}