/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2017, Open Source Geospatial Foundation (OSGeo)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotools.mbstyle;
import org.geotools.mbstyle.layer.MBLayer;
import org.geotools.mbstyle.parse.MBFormatException;
import org.geotools.mbstyle.parse.MBObjectParser;
import org.geotools.mbstyle.parse.MBObjectStops;
import org.geotools.mbstyle.source.MBSource;
import org.geotools.styling.*;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import java.awt.*;
import java.awt.geom.Point2D;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* MapBox Style implemented as wrapper around parsed JSON file.
* <p>
* This class is responsible for presenting the wrapped JSON in an easy to use / navigate form for
* Java developers:
* </p>
* <ul>
* <li>get methods: access the json directly</li>
* <li>query methods: provide logic / transforms to GeoTools classes as required.</li>
* </ul>
* <p>
* Access methods should return Java Objects, rather than generic maps. Additional access methods to
* perform common queries are expected and encouraged.
* </p>
* <p>
* This class works closely with {@link MBLayer} hierarchy used to represent the fill, line, symbol,
* raster, circle layers. Additional support will be required to work with sprites and glyphs.
* </p>
*
* @author Jody Garnett (Boundless)
*/
public class MBStyle {
/**
* JSON document being wrapped by this class.
* <p>
* All methods act as accessors on this JSON document, no other state is maintained. This
* allows modifications to be made cleaning with out chance of side-effect.
*/
public JSONObject json;
/** Helper class used to perform JSON traversal and
* perform Expression and Filter conversions. */
MBObjectParser parse = new MBObjectParser(MBStyle.class);
/**
* MBStyle wrapper on the provided json
*
* @param json Map Box Style as parsed JSON
*/
public MBStyle(JSONObject json) {
this.json = json;
}
/**
* Parse MBStyle for the provided json.
* @param json Required to be a JSONObject
* @return MBStyle wrapping the provided json
*
* @throws MBFormatException
*/
public static MBStyle create(Object json) throws MBFormatException {
if (json instanceof JSONObject) {
return new MBStyle((JSONObject) json);
} else if (json == null) {
throw new MBFormatException("JSONObject required: null");
} else {
throw new MBFormatException("Root must be a JSON Object: " + json.toString());
}
}
/**
* Access the layer with the provided id.
*
* @param id
* @return layer with the provided id, or null if not found.
*/
public MBLayer layer(String id) {
if( id == null ) {
return null;
}
for( MBLayer layer : layers() ){
if( id.equals(layer.getId())){
return layer;
}
}
return null;
}
/**
* Access all layers.
*
* @return list of layers
*/
public List<MBLayer> layers(){
JSONArray layers = parse.getJSONArray(json, "layers");
List<MBLayer> layersList = new ArrayList<>();
for (Object obj : layers) {
if (obj instanceof JSONObject) {
if (((JSONObject) obj).containsKey("ref")) {
String refLayer = ((JSONObject) obj).get("ref").toString();
JSONObject refObject = new JSONObject();
for (Object layer : layers) {
if (refLayer.equalsIgnoreCase(((JSONObject)layer).get("id").toString())) {
refObject = (JSONObject) layer;
}
}
if (refObject.size() > 0) {
// At a minimum, a type is needed to create a layer
((JSONObject) obj).put("type", refObject.get("type"));
((JSONObject) obj).put("source", refObject.get("source"));
((JSONObject) obj).put("source-layer", refObject.get("source-layer"));
((JSONObject) obj).put("minzoom", refObject.get("minzoom"));
((JSONObject) obj).put("maxzoom", refObject.get("maxzoom"));
((JSONObject) obj).put("filter", refObject.get("filter"));
if(!((JSONObject) obj).containsKey("layout")){
((JSONObject) obj).put("layout", refObject.get("layout"));
}
if(!((JSONObject) obj).containsKey("paint")){
((JSONObject) obj).put("paint", refObject.get("paint"));
}
MBLayer layer = MBLayer.create((JSONObject) obj);
layersList.add(layer);
}
} else {
MBLayer layer = MBLayer.create((JSONObject) obj);
layersList.add(layer);
}
} else {
throw new MBFormatException("Unexpected layer definition " + obj);
}
}
return layersList;
}
/**
* Access layers matching provided source.
*
* @param source
* @return list of layers matching provided source
*/
public List<MBLayer> layers(String source) throws MBFormatException {
JSONArray layers = parse.getJSONArray(json, "layers");
List<MBLayer> layersList = new ArrayList<>();
for (Object obj : layers) {
if (obj instanceof JSONObject) {
if (((JSONObject) obj).containsKey("ref")) {
String refLayer = ((JSONObject) obj).get("ref").toString();
JSONObject refObject = new JSONObject();
for (Object layer : layers) {
if (refLayer.equalsIgnoreCase(((JSONObject)layer).get("id").toString())) {
refObject = (JSONObject) layer;
}
}
if (refObject.size() > 0) {
((JSONObject) obj).put("type", refObject.get("type"));
((JSONObject) obj).put("source", refObject.get("source"));
((JSONObject) obj).put("source-layer", refObject.get("source-layer"));
((JSONObject) obj).put("minzoom", refObject.get("minzoom"));
((JSONObject) obj).put("maxzoom", refObject.get("maxzoom"));
((JSONObject) obj).put("filter", refObject.get("filter"));
if(!((JSONObject) obj).containsKey("layout")){
((JSONObject) obj).put("layout", refObject.get("layout"));
}
if(!((JSONObject) obj).containsKey("paint")){
((JSONObject) obj).put("paint", refObject.get("paint"));
}
MBLayer layer = MBLayer.create((JSONObject) obj);
layersList.add(layer);
}
} else {
MBLayer layer = MBLayer.create((JSONObject) obj);
layersList.add(layer);
}
} else {
throw new MBFormatException("Unexpected layer definition " + obj);
}
}
return layersList;
}
/**
* A human-readable name for the style
*
* @return human-readable name, or "name" if the style has no name.
*/
public String getName() {
return parse.optional(String.class, json, "name", null);
}
/**
* (Optional) Arbitrary properties useful to track with the stylesheet, but do not influence rendering. Properties should be prefixed to avoid
* collisions, like 'mapbox:'.
*
* @return {@link JSONObject} containing the metadata, or an empty JSON object the style has no metadata.
*/
public JSONObject getMetadata() {
return parse.getJSONObject(json, "metadata", new JSONObject());
}
/**
* (Optional) Default map center in longitude and latitude. The style center will be used only if the map has not been positioned by other means (e.g. map options or user interaction).
* @return A {@link Point} for the map center, or null if the style contains no center.
*/
public Point2D getCenter() {
double[] coords = parse.array(json, "center", (double[])null);
if (coords == null) {
return null;
} else if (coords.length != 2){
throw new MBFormatException("\"center\" array must be length 2.");
} else {
return new Point2D.Double(coords[0], coords[1]);
}
}
/**
* (Optional) Default zoom level. The style zoom will be used only if the map has not been positioned by other means (e.g. map options or user interaction).
*
* @return Number for the zoom level, or null if the style has no default zoom level.
*/
public Number getZoom() {
return parse.optional(Number.class, json, "zoom", null);
}
/**
* (Optional) Default bearing, in degrees clockwise from true north. The style bearing will be used only if the map has not been positioned by
* other means (e.g. map options or user interaction).
*
* @return The bearing in degrees. Defaults to 0 if the style has no bearing.
*
*/
public Number getBearing() {
return parse.optional(Number.class, json, "bearing", 0);
}
/**
* (Optional) Default pitch, in degrees. Zero is perpendicular to the surface, for a look straight down at the map, while a greater value like 60
* looks ahead towards the horizon. The style pitch will be used only if the map has not been positioned by other means (e.g. map options or user
* interaction).
*
* @return The pitch in degrees. Defaults to 0 if the style has no pitch.
*/
public Number getPitch() {
return parse.optional(Number.class, json, "pitch", 0);
}
/**
* A base URL for retrieving the sprite image and metadata. The extensions .png, .json and scale factor @2x.png will be automatically appended.
* This property is required if any layer uses the background-pattern, fill-pattern, line-pattern, fill-extrusion-pattern, or icon-image
* properties.
*
* @return The sprite URL, or null if the style has no sprite URL.
*/
public String getSprite() {
return parse.optional(String.class, json, "sprite", null);
}
/**
* (Optional) A URL template for loading signed-distance-field glyph sets in PBF format. The URL must include {fontstack} and {range} tokens. This
* property is required if any layer uses the text-field layout property.
* <br/>
* Example:
* <br/>
* <code>"glyphs": "mapbox://fonts/mapbox/{fontstack}/{range}.pbf"</code>
*
* @return The glyphs URL template, or null if the style has no glyphs URL template.
*/
public String getGlyphs() {
return parse.optional(String.class, json, "glyphs", null);
}
/**
* Data source specifications.
*
* @see {@link MBSource} and its subclasses.
* @return Map of data source name -> {@link MBSource} instances.
*/
public Map<String, MBSource> getSources() {
Map<String, MBSource> sourceMap = new HashMap<>();
JSONObject sources = parse.getJSONObject(json, "sources", new JSONObject());
for (Object o: sources.keySet()) {
if (o instanceof String) {
String k = (String) o;
JSONObject j = parse.getJSONObject(sources, k);
MBSource s = MBSource.create(j, parse);
sourceMap.put(k, s);
}
}
return sourceMap;
}
/**
* Transform MBStyle to a GeoTools StyledLayerDescriptor.
*
* @return StyledLayerDescriptor
*/
public StyledLayerDescriptor transform() {
StyleFactory sf = parse.getStyleFactory();
List<MBLayer> layers = layers();
if (layers.isEmpty()) {
throw new MBFormatException("layers empty");
}
StyledLayerDescriptor sld = sf.createStyledLayerDescriptor();
Style style = sf.createStyle();
for (MBLayer layer : layers) {
MBObjectStops mbObjectStops = new MBObjectStops(layer);
int layerMaxZoom = layer.getMaxZoom();
int layerMinZoom = layer.getMinZoom();
Double layerMinScaleDenominator = layerMaxZoom == Integer.MAX_VALUE ? null
: MBObjectStops.zoomLevelToScaleDenominator((long) Math.min(25, layerMaxZoom));
Double layerMaxScaleDenominator = layerMinZoom == Integer.MIN_VALUE ? null
: MBObjectStops.zoomLevelToScaleDenominator((long) Math.max(-25, layerMinZoom));
if (layer.visibility()) {
List<FeatureTypeStyle> featureTypeStyle = null;
// check for property and zoom functions, if true we will have a layer for each one that
// becomes a feature type style.
if (mbObjectStops.hasStops) {
List<Long> stopLevels = mbObjectStops.stops;
int i = 0;
for (MBLayer l : mbObjectStops.layersForStop) {
long s = stopLevels.get(i);
long[] rangeForStopLevel = mbObjectStops.getRangeForStop(s, mbObjectStops.ranges);
Double maxScaleDenominator = MBObjectStops.zoomLevelToScaleDenominator(rangeForStopLevel[0]);
Double minScaleDenominator = null;
if (rangeForStopLevel[1] != -1) {
minScaleDenominator = MBObjectStops.zoomLevelToScaleDenominator(rangeForStopLevel[1]);
}
featureTypeStyle = l.transform(this, minScaleDenominator, maxScaleDenominator);
style.featureTypeStyles().addAll(featureTypeStyle);
i++;
}
} else {
featureTypeStyle = layer.transform(this, layerMinScaleDenominator, layerMaxScaleDenominator);
style.featureTypeStyles().addAll(featureTypeStyle);
}
}
}
if( style.featureTypeStyles().isEmpty() ){
throw new MBFormatException("No visibile layers");
}
UserLayer userLayer = sf.createUserLayer();
userLayer.userStyles().add(style);
sld.layers().add(userLayer);
sld.setName(getName());
return sld;
}
}