/* (c) 2017 Open Source Geospatial Foundation - all rights reserved
* This code is licensed under the GPL 2.0 license, available at the root
* application directory.
*/
package org.geoserver.wms.legendgraphic;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints.Key;
import java.awt.image.IndexColorModel;
import java.awt.image.RenderedImage;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.commons.lang.StringUtils;
import org.geoserver.catalog.NamespaceInfo;
import org.geoserver.ows.util.CaseInsensitiveMap;
import org.geoserver.platform.ServiceException;
import org.geoserver.wms.GetLegendGraphicRequest;
import org.geoserver.wms.GetLegendGraphicRequest.LegendRequest;
import org.geoserver.wms.GetMap;
import org.geoserver.wms.GetMapRequest;
import org.geoserver.wms.WMS;
import org.geoserver.wms.map.GetMapKvpRequestReader;
import org.geoserver.wms.map.RenderedImageMapOutputFormat;
import org.geotools.factory.CommonFactoryFinder;
import org.geotools.renderer.RenderListener;
import org.geotools.renderer.lite.StreamingRenderer;
import org.geotools.styling.AbstractStyleVisitor;
import org.geotools.styling.DescriptionImpl;
import org.geotools.styling.FeatureTypeStyle;
import org.geotools.styling.Mark;
import org.geotools.styling.PointSymbolizer;
import org.geotools.styling.Rule;
import org.geotools.styling.Style;
import org.geotools.styling.StyleFactory2;
import org.geotools.styling.TextSymbolizer;
import org.geotools.styling.visitor.DuplicatingStyleVisitor;
import org.geotools.util.SimpleInternationalString;
import org.opengis.feature.Feature;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.feature.type.Name;
import org.opengis.style.Description;
/**
* Alters a legend to add a count of the rules descriptions
*
* @author Andrea Aime - GeoSolutions
*/
class FeatureCountProcessor {
static final StyleFactory2 SF = (StyleFactory2) CommonFactoryFinder.getStyleFactory();
/**
* Updates a rule setting its description's title as the provided targetLabel
* description)
*
* @author Andrea Aime - GeoSolutions
*/
private static final class TargetLabelUpdater extends DuplicatingStyleVisitor {
private String targetLabel;
public TargetLabelUpdater(String targetLabel) {
this.targetLabel = targetLabel;
}
@Override
public void visit(Rule rule) {
super.visit(rule);
Rule copy = (Rule) pages.peek();
Description description = new DescriptionImpl(
new SimpleInternationalString(targetLabel),
copy.getDescription() != null ? copy.getDescription().getAbstract() : null);
copy.setDescription(description);
}
}
/**
* Runs a map generation on an empty graphics object and allows to consume each feature that gets rendered
*
* @author Andrea Aime - GeoSolutions
*/
private static final class FeatureRenderSpyFormat extends RenderedImageMapOutputFormat {
private Consumer<Feature> consumer;
private FeatureRenderSpyFormat(WMS wms, Consumer<Feature> consumer) {
super(wms);
this.consumer = consumer;
}
@Override
protected RenderedImage prepareImage(int width, int height, IndexColorModel palette,
boolean transparent) {
return null;
}
@Override
protected Graphics2D getGraphics(boolean transparent, Color bgColor,
RenderedImage preparedImage, Map<Key, Object> hintsMap) {
return new NoOpGraphics2D();
}
@Override
protected void onBeforeRender(StreamingRenderer renderer) {
super.onBeforeRender(renderer);
renderer.addRenderListener(new RenderListener() {
@Override
public void featureRenderer(SimpleFeature feature) {
consumer.accept(feature);
}
@Override
public void errorOccurred(Exception e) {
// nothing to do here
}
});
}
}
/**
* Checks if there are rules in match first mode
*
* @author Andrea Aime - GeoSolutions
*/
private static class MatchFirstVisitor extends AbstractStyleVisitor {
boolean matchFirst = false;
@Override
public void visit(FeatureTypeStyle fts) {
// yes, it's an approximation, we cannot really work with a mix of FTS that
// are some evaluate first, others non evaluate first, but the case is so narrow
// that I'm inclined to wait for dedicated funding before going there
matchFirst |= FeatureTypeStyle.VALUE_EVALUATION_MODE_FIRST.equals(fts.getOptions().get(
FeatureTypeStyle.KEY_EVALUATION_MODE));
}
}
/**
* Replaces labels with small points for the sake of feature counting
*
* @author Andrea Aime - GeoSolutions
*/
private static class LabelReplacer extends DuplicatingStyleVisitor {
PointSymbolizer ps;
LabelReplacer() {
ps = sf.createPointSymbolizer();
ps.getGraphic().graphicalSymbols().add(sf.createMark());
}
@Override
public void visit(TextSymbolizer text) {
pages.push(ps);
}
}
private GetLegendGraphicRequest request;
private GetMapKvpRequestReader getMapReader;
/**
* Builds a new feature count processor given the legend graphic request. It can be used to
* alter with feature counts many rule sets.
* @param request
*/
public FeatureCountProcessor(GetLegendGraphicRequest request) {
this.request = request;
this.getMapReader = new GetMapKvpRequestReader(request.getWms());
}
/**
* Pre-processes the legend request and returns a style whose rules have been altered to contain a feature count
*
* @param legend
* @return
* @throws Exception
*/
public Rule[] preProcessRules(LegendRequest legend, Rule[] rules) {
if (rules == null || rules.length == 0) {
return rules;
}
// is the code running in match first mode?
MatchFirstVisitor matchFirstVisitor = new MatchFirstVisitor();
legend.getStyle().accept(matchFirstVisitor);
boolean matchFirst = matchFirstVisitor.matchFirst;
try {
GetMapRequest getMapRequest = parseAssociatedGetMap(legend, rules);
Map<Rule, AtomicInteger> counters = renderAndCountFeatures(rules, getMapRequest, matchFirst);
Rule[] result = updateRuleTitles(rules, counters);
return result;
} catch (ServiceException ex) {
throw ex;
} catch (Exception ex) {
throw new ServiceException(ex);
}
}
private Rule[] updateRuleTitles(Rule[] rules, Map<Rule, AtomicInteger> counters) {
Rule[] result = new Rule[rules.length];
for (int i = 0; i < rules.length; i++) {
Rule rule = rules[i];
AtomicInteger counter = counters.get(rule);
String label = LegendUtils.getRuleLabel(rule, request);
if (StringUtils.isEmpty(label)) {
label = "(" + counter.get() + ")";
} else {
label = label + " (" + counter.get() + ")";
}
TargetLabelUpdater duplicatingVisitor = new TargetLabelUpdater(label);
rule.accept(duplicatingVisitor);
Rule clone = (Rule) duplicatingVisitor.getCopy();
result[i] = clone;
}
return result;
}
private Map<Rule, AtomicInteger> renderAndCountFeatures(Rule[] rules,
GetMapRequest getMapRequest, boolean matchFirst) {
final WMS wms = request.getWms();
// the counters for each rule, all initialized at zero
Map<Rule, AtomicInteger> counters = Arrays.stream(rules)
.collect(Collectors.toMap(Function.identity(), r -> new AtomicInteger(0)));
// run and count
GetMap getMap = new GetMap(wms) {
protected org.geoserver.wms.GetMapOutputFormat getDelegate(String outputFormat)
throws ServiceException {
return new FeatureRenderSpyFormat(wms, f -> {
boolean matched = false;
for (Rule rule : rules) {
if(rule.isElseFilter()) {
if(!matched) {
AtomicInteger counter = counters.get(rule);
counter.incrementAndGet();
}
} else if (rule.getFilter() == null || rule.getFilter().evaluate(f)) {
AtomicInteger counter = counters.get(rule);
counter.incrementAndGet();
if(matchFirst) {
break;
}
}
}
});
};
};
getMap.run(getMapRequest);
return counters;
}
/**
* Parse the equivalent GetMap for this layer
*
* @param legend
* @param rules
* @return
* @throws Exception
*/
private GetMapRequest parseAssociatedGetMap(LegendRequest legend, Rule[] rules)
throws Exception {
// setup the KVP for the internal, fake GetMap
Map<String, Object> kvp = new CaseInsensitiveMap(request.getKvp());
Map<String, String> rawKvp = new CaseInsensitiveMap(request.getRawKvp());
// ... the actual layer
String layerName = getLayerName(legend);
kvp.put("LAYERS", layerName);
rawKvp.put("LAYERS", layerName);
// ... a default style we'll override later
kvp.put("STYLES", "");
rawKvp.put("STYLES", "");
// ... width and height
rawKvp.put("WIDTH", rawKvp.get("SRCWIDTH"));
rawKvp.put("HEIGTH", rawKvp.get("SRCHEIGHT"));
// remove decoration to avoid infinite recursion
final Map formatOptions = (Map) kvp.get("FORMAT_OPTIONS");
if(formatOptions != null) {
formatOptions.remove("layout");
}
// parse
GetMapRequest getMap = getMapReader.read(getMapReader.createRequest(), kvp, rawKvp);
// replace style with the current set of rules
Style style = buildStyleFromRules(rules);
getMap.setStyles(Arrays.asList(style));
return getMap;
}
private String getLayerName(LegendRequest legend) {
if(legend.getLayer() != null) {
return legend.getLayer();
} else if(legend.getLayerInfo() != null) {
return legend.getLayerInfo().prefixedName();
} else if(legend.getFeatureType() != null) {
Name name = legend.getFeatureType().getName();
NamespaceInfo ns = request.getWms().getCatalog().getNamespaceByURI(name.getNamespaceURI());
final String localName = name.getLocalPart();
if(ns != null) {
return ns.getPrefix() + ":" + localName;
} else {
return localName;
}
} else {
// should not really happen today, but who knows, may do in the future
throw new ServiceException("Could not get the layer name out of " + legend);
}
}
private Style buildStyleFromRules(Rule[] rules) {
// prepare based on rules
FeatureTypeStyle fts = SF.createFeatureTypeStyle();
fts.rules().addAll(Arrays.asList(rules));
Style style = SF.createStyle();
style.featureTypeStyles().add(fts);
// replace labels with points (labels report about features that are not
// really in the viewport only because the lax geometry check loaded them,
// at the same time we cannot do a true intersection test for a variety or reasons,
// for example, in place reprojection, rendering transformations, advanced projection handling)
LabelReplacer replacer = new LabelReplacer();
style.accept(replacer);
return (Style) replacer.getCopy();
}
}