diff artifacts/src/main/java/org/dive4elements/river/exports/AbstractChartGenerator.java @ 9123:1cc7653ca84f

Cleanup of ChartGenerator and ChartGenerator2 code. Put some of the copy/pasted code into a common abstraction.
author gernotbelger
date Tue, 05 Jun 2018 19:21:16 +0200
parents 07d51fd4864c
children eec4df8165a1
line wrap: on
line diff
--- a/artifacts/src/main/java/org/dive4elements/river/exports/AbstractChartGenerator.java	Tue Jun 05 19:10:38 2018 +0200
+++ b/artifacts/src/main/java/org/dive4elements/river/exports/AbstractChartGenerator.java	Tue Jun 05 19:21:16 2018 +0200
@@ -9,52 +9,499 @@
  */
 package org.dive4elements.river.exports;
 
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Paint;
+import java.awt.Stroke;
+import java.awt.TexturePaint;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.text.DateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
 import javax.xml.xpath.XPathConstants;
 
+import org.apache.log4j.Logger;
+import org.dive4elements.artifactdatabase.state.ArtifactAndFacet;
+import org.dive4elements.artifactdatabase.state.Settings;
+import org.dive4elements.artifacts.Artifact;
 import org.dive4elements.artifacts.ArtifactNamespaceContext;
 import org.dive4elements.artifacts.CallContext;
+import org.dive4elements.artifacts.CallMeta;
+import org.dive4elements.artifacts.PreferredLocale;
 import org.dive4elements.artifacts.common.utils.XMLUtils;
+import org.dive4elements.river.FLYS;
 import org.dive4elements.river.artifacts.D4EArtifact;
 import org.dive4elements.river.artifacts.access.RangeAccess;
 import org.dive4elements.river.artifacts.access.RiverAccess;
+import org.dive4elements.river.artifacts.resources.Resources;
+import org.dive4elements.river.artifacts.sinfo.util.CalculationUtils;
+import org.dive4elements.river.collections.D4EArtifactCollection;
+import org.dive4elements.river.jfree.AxisDataset;
+import org.dive4elements.river.jfree.Bounds;
+import org.dive4elements.river.jfree.DoubleBounds;
+import org.dive4elements.river.jfree.EnhancedLineAndShapeRenderer;
+import org.dive4elements.river.jfree.RiverAnnotation;
+import org.dive4elements.river.jfree.StableXYDifferenceRenderer;
+import org.dive4elements.river.jfree.Style;
+import org.dive4elements.river.jfree.StyledAreaSeriesCollection;
+import org.dive4elements.river.jfree.StyledSeries;
+import org.dive4elements.river.themes.ThemeDocument;
+import org.dive4elements.river.utils.Formatter;
 import org.jfree.chart.JFreeChart;
+import org.jfree.chart.LegendItem;
+import org.jfree.chart.LegendItemCollection;
+import org.jfree.chart.axis.NumberAxis;
+import org.jfree.chart.plot.XYPlot;
+import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
 import org.jfree.chart.title.TextTitle;
+import org.jfree.data.Range;
+import org.jfree.data.general.Series;
+import org.jfree.data.xy.XYDataset;
+import org.jfree.ui.RectangleInsets;
 import org.w3c.dom.Document;
+import org.w3c.dom.Element;
 
 /**
+ * This class re-unites the tremendous copy/paste code from ChartGenerator and ChartGenerator2. Code is still awful and
+ * encapsulation is broken in too many places.
+ * TODO: instead of deep inheritances, delegate to classes that define the various behaviors.
+ *
  * @author Gernot Belger
  */
-// FIXME: this class is intended to contain all duplicate code from ChartGenerator and ChartGenerator2; who will clean
-// up this mess...?
 abstract class AbstractChartGenerator implements OutGenerator {
+
+    protected static final Logger log = Logger.getLogger(AbstractChartGenerator.class);
+
+    private static final int DEFAULT_CHART_WIDTH = 600;
+
+    private static final int DEFAULT_CHART_HEIGHT = 400;
+
+    private static final Color DEFAULT_GRID_COLOR = Color.GRAY;
+
+    private static final float DEFAULT_GRID_LINE_WIDTH = 0.3f;
+
+    protected static final String DEFAULT_FONT_NAME = "Tahoma";
+
+    protected static final int DEFAULT_FONT_SIZE = 12;
+
+    private static final String DEFAULT_CHART_FORMAT = "png";
+
     private static final String XPATH_CHART_EXPORT = "/art:action/art:attributes/art:export/@art:value";
 
-    // TODO: move real code here
-    protected abstract D4EArtifact getArtifact();
+    private static final String XPATH_CHART_SIZE = "/art:action/art:attributes/art:size";
+
+    private static final String XPATH_CHART_FORMAT = "/art:action/art:attributes/art:format/@art:value";
+
+    private static final String XPATH_CHART_X_RANGE = "/art:action/art:attributes/art:xrange";
+
+    private static final String XPATH_CHART_Y_RANGE = "/art:action/art:attributes/art:yrange";
+
+    /** The document of the incoming out() request. */
+    private Document request;
+
+    /** The output stream where the data should be written to. */
+    private OutputStream out;
+
+    /** Artifact that is used to decorate the chart with meta information. */
+    private Artifact master;
+
+    /** Map of datasets ("index"). */
+    private final SortedMap<Integer, AxisDataset> datasets = new TreeMap<>();
+
+    /** List of annotations to insert in plot. */
+    private final List<RiverAnnotation> annotations = new ArrayList<>();
+
+    private String outName;
+
+    /** The settings that should be used during output creation. */
+    private Settings settings;
 
     /** The CallContext object. */
-    // TODO: move real code here
-    protected abstract CallContext getContext();
+    private CallContext context;
+
+    @Override
+    public void init(final String outName, final Document request, final OutputStream out, final CallContext context) {
+        log.debug("ChartGenerator.init");
+
+        this.outName = outName;
+        this.request = request;
+        this.out = out;
+        this.context = context;
+    }
+
+    @Override
+    public void setup(final Object config) {
+    }
+
+    /** Sets the master artifact. */
+    @Override
+    public void setMasterArtifact(final Artifact master) {
+        this.master = master;
+    }
+
+    /**
+     * Gets the master artifact.
+     *
+     * @return the master artifact.
+     */
+    public Artifact getMaster() {
+        return this.master;
+    }
+
+    protected final Map<Integer, AxisDataset> getDatasets() {
+        return this.datasets;
+    }
+
+    @Override
+    public void setCollection(final D4EArtifactCollection collection) {
+        /* we do not need it */
+    }
+
+    protected final D4EArtifact getArtifact() {
+        // FIXME: should already made sure when this member is set
+        return (D4EArtifact) this.master;
+    }
+
+    public final CallContext getContext() {
+        return this.context;
+    }
 
     /** The document of the incoming out() request. */
-    // TODO: move real code here
-    protected abstract Document getRequest();
+    protected final Document getRequest() {
+        return this.request;
+    }
+
+    /**
+     * Adds annotations to list. The given annotation will be visible.
+     */
+    public final void addAnnotations(final RiverAnnotation annotation) {
+        this.annotations.add(annotation);
+    }
+
+    /**
+     * This method needs to be implemented by concrete subclasses to create new
+     * instances of JFreeChart.
+     *
+     * @param context2
+     *
+     * @return a new instance of a JFreeChart.
+     */
+    protected abstract JFreeChart generateChart(CallContext context2);
+
+    /**
+     * For every outable (i.e. facets), this function is
+     * called and handles the data accordingly.
+     */
+    @Override
+    public abstract void doOut(ArtifactAndFacet bundle, ThemeDocument attr, boolean visible);
+
+    @Override
+    public void generate() throws IOException {
+        doGenerate(this.context, this.out, this.outName);
+    }
+
+    protected abstract void doGenerate(CallContext context, OutputStream out, String outName) throws IOException;
+
+    protected abstract Series getSeriesOf(XYDataset dataset, int idx);
+
+    /**
+     * Returns the default title of a chart.
+     *
+     * @param context2
+     *
+     * @return the default title of a chart.
+     */
+    protected abstract String getDefaultChartTitle(CallContext context);
+
+    /**
+     * This method is used to create new AxisDataset instances which may differ
+     * in concrete subclasses.
+     *
+     * @param idx
+     *            The index of an axis.
+     */
+    protected abstract AxisDataset createAxisDataset(int idx);
+
+    /**
+     * Combines the ranges of the X axis at index <i>idx</i>.
+     *
+     * @param bounds
+     *            A new Bounds.
+     * @param idx
+     *            The index of the X axis that should be comined with
+     *            <i>range</i>.
+     */
+    protected abstract void combineXBounds(Bounds bounds, int idx);
+
+    /**
+     * Combines the ranges of the Y axis at index <i>idx</i>.
+     *
+     * @param bounds
+     *            A new Bounds.
+     * @param index
+     *            The index of the Y axis that should be comined with.
+     *            <i>range</i>.
+     */
+    protected abstract void combineYBounds(Bounds bounds, int index);
+
+    /**
+     * This method is used to determine the ranges for axes at a given index.
+     *
+     * @param index
+     *            The index of the axes at the plot.
+     *
+     * @return a Range[] with [xrange, yrange];
+     */
+    protected abstract Range[] getRangesForAxis(int index);
+
+    protected abstract Bounds getXBounds(int axis);
+
+    protected abstract void setXBounds(int axis, Bounds bounds);
+
+    protected abstract Bounds getYBounds(int axis);
+
+    protected abstract void setYBounds(int axis, Bounds bounds);
+
+    // /**
+    // * Retuns the call context. May be null if init hasn't been called yet.
+    // *
+    // * @return the CallContext instance
+    // */
+    // protected final CallContext getCallContext() {
+    // return this.context;
+    // }
+    //
+
+    @Override
+    public final void setSettings(final Settings settings) {
+        this.settings = settings;
+    }
+
+    /**
+     * Returns the <i>settings</i> as <i>ChartSettings</i>.
+     *
+     * @return the <i>settings</i> as <i>ChartSettings</i> or null, if
+     *         <i>settings</i> is not an instance of <i>ChartSettings</i>.
+     */
+    protected final ChartSettings getChartSettings() {
+        if (this.settings instanceof ChartSettings) {
+            return (ChartSettings) this.settings;
+        }
+
+        return null;
+    }
+
+    /**
+     * Return instance of <i>ChartSettings</i> with a chart specific section
+     * but with no axes settings.
+     *
+     * @return an instance of <i>ChartSettings</i>.
+     */
+    @Override
+    public final Settings getSettings() {
+        if (this.settings != null)
+            return this.settings;
+
+        final ChartSettings settings = new ChartSettings();
+
+        final ChartSection chartSection = buildChartSection(this.context);
+        final LegendSection legendSection = buildLegendSection();
+        final ExportSection exportSection = buildExportSection();
+
+        settings.setChartSection(chartSection);
+        settings.setLegendSection(legendSection);
+        settings.setExportSection(exportSection);
+
+        final List<AxisSection> axisSections = buildAxisSections();
+        for (final AxisSection axisSection : axisSections)
+            settings.addAxisSection(axisSection);
+
+        return settings;
+    }
+
+    protected abstract ChartSection buildChartSection(CallContext context);
+
+    /**
+     * Creates a new <i>LegendSection</i>.
+     *
+     * @return a new <i>LegendSection</i>.
+     */
+    private LegendSection buildLegendSection() {
+        final LegendSection legendSection = new LegendSection();
+        legendSection.setVisibility(isLegendVisible());
+        legendSection.setFontSize(getLegendFontSize());
+        legendSection.setAggregationThreshold(10);
+        return legendSection;
+    }
+
+    /**
+     * Creates a new <i>ExportSection</i> with default values <b>WIDTH=600</b>
+     * and <b>HEIGHT=400</b>.
+     *
+     * @return a new <i>ExportSection</i>.
+     */
+    private ExportSection buildExportSection() {
+        final ExportSection exportSection = new ExportSection();
+        exportSection.setWidth(DEFAULT_CHART_WIDTH);
+        exportSection.setHeight(DEFAULT_CHART_HEIGHT);
+        exportSection.setMetadata(true);
+        return exportSection;
+    }
+
+    /**
+     * Creates a list of Sections that contains all axes of the chart (including
+     * X and Y axes).
+     *
+     * @return a list of Sections for each axis in this chart.
+     */
+    private List<AxisSection> buildAxisSections() {
+        final List<AxisSection> axisSections = new ArrayList<>();
+
+        axisSections.addAll(buildXAxisSections());
+        axisSections.addAll(buildYAxisSections());
+
+        return axisSections;
+    }
+
+    /**
+     * Creates a new Section for chart's X axis.
+     *
+     * @return a List that contains a Section for the X axis.
+     */
+    protected List<AxisSection> buildXAxisSections() {
+        final List<AxisSection> axisSections = new ArrayList<>();
+
+        final String identifier = "X";
+
+        final AxisSection axisSection = new AxisSection();
+        axisSection.setIdentifier(identifier);
+        axisSection.setLabel(getXAxisLabel());
+        axisSection.setFontSize(14);
+        axisSection.setFixed(false);
+
+        // XXX We are able to find better default ranges that [0,0], but the Y
+        // axes currently have no better ranges set.
+        axisSection.setUpperRange(0d);
+        axisSection.setLowerRange(0d);
+
+        axisSections.add(axisSection);
+
+        return axisSections;
+    }
+
+    /**
+     * Returns the X-Axis label of a chart.
+     *
+     * @return the X-Axis label of a chart.
+     */
+    protected final String getXAxisLabel() {
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings == null) {
+            return getDefaultXAxisLabel(this.context);
+        }
+
+        final AxisSection as = chartSettings.getAxisSection("X");
+        if (as != null) {
+            final String label = as.getLabel();
+
+            if (label != null) {
+                return label;
+            }
+        }
+
+        return getDefaultXAxisLabel(this.context);
+    }
+
+    protected abstract List<AxisSection> buildYAxisSections();
+
+    /**
+     * Returns the default X-Axis label of a chart.
+     *
+     * @param context2
+     *
+     * @return the default X-Axis label of a chart.
+     */
+    protected abstract String getDefaultXAxisLabel(final CallContext context2);
+
+    /** Generate the diagram as an image. */
+    protected final void generateImage(final CallContext context) throws IOException {
+        log.debug("ChartGenerator2.generateImage");
+
+        final JFreeChart chart = generateChart(context);
+
+        final String format = getFormat();
+        int[] size = getSize();
+
+        if (size == null)
+            size = getExportDimension();
+
+        this.context.putContextValue("chart.width", size[0]);
+        this.context.putContextValue("chart.height", size[1]);
+
+        if (format.equals(ChartExportHelper.FORMAT_PNG)) {
+            this.context.putContextValue("chart.image.format", "png");
+
+            ChartExportHelper.exportImage(this.out, chart, this.context);
+        } else if (format.equals(ChartExportHelper.FORMAT_PDF)) {
+            preparePDFContext(this.context);
+
+            ChartExportHelper.exportPDF(this.out, chart, this.context);
+        } else if (format.equals(ChartExportHelper.FORMAT_SVG)) {
+            prepareSVGContext(this.context);
+
+            ChartExportHelper.exportSVG(this.out, chart, this.context);
+        } else if (format.equals(ChartExportHelper.FORMAT_CSV)) {
+            this.context.putContextValue("chart.image.format", "csv");
+
+            ChartExportHelper.exportCSV(this.out, chart, this.context);
+        }
+    }
 
     /**
      * Adds a metadata sub-title to the chart if it gets exported
      */
-    protected final void addMetadataSubtitle(final JFreeChart chart) {
-        if (isExport()) {
-            final String text = ChartExportHelper.createMetadataSubtitle(getArtifact(), getContext(), getRiverName());
-            chart.addSubtitle(new TextTitle(text));
-        }
+    protected final void addMetadataSubtitle(final CallContext context, final JFreeChart chart) {
+        if ((!isExport() || !isExportMetadata()))
+            return;
+
+        final String version = FLYS.VERSION;
+        final String user = CalculationUtils.findArtifactUser(context, getArtifact());
+        final Locale locale = Resources.getLocale(context.getMeta());
+        final DateFormat df = DateFormat.getDateInstance(DateFormat.SHORT, locale);
+        final String dateText = df.format(new Date());
+
+        final String text = Resources.getMsg(context.getMeta(), "chart.subtitle.metadata", "default", version, user, dateText);
+
+        chart.addSubtitle(new TextTitle(text));
+    }
+
+    private boolean isExportMetadata() {
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings == null)
+            return true;
+
+        final ExportSection exportSection = chartSettings.getExportSection();
+        if (exportSection == null)
+            return true;
+
+        return exportSection.getMetadata();
     }
 
     /**
      * This method returns the export flag specified in the <i>request</i> document
      * or <i>false</i> if no export is specified in <i>request</i>.
      */
-    protected final boolean isExport() {
+    private boolean isExport() {
         final Boolean export = (Boolean) XMLUtils.xpath(getRequest(), XPATH_CHART_EXPORT, XPathConstants.BOOLEAN, ArtifactNamespaceContext.INSTANCE);
 
         return export == null ? false : export;
@@ -74,4 +521,790 @@
         final RangeAccess rangeAccess = new RangeAccess(flys);
         return rangeAccess.getKmRange();
     }
+
+    /**
+     * Returns a boolean object that determines if the chart grid should be
+     * visible or not. This information needs to be provided by <i>settings</i>,
+     * otherwise the default is true.
+     *
+     * @param settings
+     *            A ChartSettings object.
+     *
+     * @return true, if the chart grid should be visible otherwise false.
+     *
+     * @throws NullPointerException
+     *             if <i>settings</i> is null.
+     */
+    private boolean isGridVisible(final ChartSettings settings) {
+        final ChartSection cs = settings.getChartSection();
+        return cs.getDisplayGrid();
+    }
+
+    /**
+     * This method is used to determine, if the chart's legend is visible or
+     * not. If a <i>settings</i> instance is set, this instance determines the
+     * visibility otherwise, this method returns true as default if no
+     * <i>settings</i> is set.
+     *
+     * @return true, if the legend should be visible, otherwise false.
+     */
+    protected final boolean isLegendVisible() {
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings == null)
+            return true;
+
+        final LegendSection ls = chartSettings.getLegendSection();
+        return ls.getVisibility();
+    }
+
+    /**
+     * This method returns the font size for the X axis. If the font size is
+     * specified in ChartSettings (if <i>chartSettings</i> is set), this size is
+     * returned. Otherwise the default font size 12 is returned.
+     *
+     * @return the font size for the x axis.
+     */
+    protected final int getXAxisLabelFontSize() {
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings == null) {
+            return DEFAULT_FONT_SIZE;
+        }
+
+        final AxisSection as = chartSettings.getAxisSection("X");
+        final Integer fontSize = as.getFontSize();
+
+        return fontSize != null ? fontSize : DEFAULT_FONT_SIZE;
+    }
+
+    /**
+     * This method is used to determine the font size of the chart's legend. If
+     * a <i>settings</i> instance is set, this instance determines the font
+     * size, otherwise this method returns 12 as default if no <i>settings</i>
+     * is set or if it doesn't provide a legend font size.
+     *
+     * @return a legend font size.
+     */
+    private int getLegendFontSize() {
+
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings == null)
+            return DEFAULT_FONT_SIZE;
+
+        final LegendSection ls = chartSettings.getLegendSection();
+        if (ls == null)
+            return DEFAULT_FONT_SIZE;
+
+        final Integer fontSize = ls.getFontSize();
+        if (fontSize == null)
+            return DEFAULT_FONT_SIZE;
+
+        return fontSize;
+    }
+
+    /**
+     * Creates a new LegendItem with <i>name</i> and font provided by
+     * <i>createLegendLabelFont()</i>.
+     *
+     * @param theme
+     *            The theme of the chart line.
+     * @param name
+     *            The displayed name of the item.
+     *
+     * @return a new LegendItem instance.
+     */
+    protected final LegendItem createLegendItem(final ThemeDocument theme, final String name) {
+        // OPTIMIZE Pass font, parsed Theme items.
+
+        Color color = theme.parseLineColorField();
+        if (color == null)
+            color = Color.BLACK;
+
+        final LegendItem legendItem = new LegendItem(name, color);
+        legendItem.setLabelFont(createLegendLabelFont());
+        return legendItem;
+    }
+
+    /**
+     * Create new legend entries, dependent on settings.
+     *
+     * @param plot
+     *            The plot for which to modify the legend.
+     */
+    protected final void aggregateLegendEntries(final XYPlot plot) {
+
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings == null)
+            return;
+
+        final Integer threshold = chartSettings.getLegendSection().getAggregationThreshold();
+
+        final int aggrThreshold = threshold != null ? threshold.intValue() : 0;
+
+        LegendProcessor.aggregateLegendEntries(plot, aggrThreshold);
+    }
+
+    /**
+     * Creates Font (Family and size) to use when creating Legend Items. The
+     * font size depends in the return value of <i>getLegendFontSize()</i>.
+     *
+     * @return a new Font instance with <i>DEFAULT_FONT_NAME</i>.
+     */
+    private final Font createLegendLabelFont() {
+        return new Font(DEFAULT_FONT_NAME, Font.PLAIN, getLegendFontSize());
+    }
+
+    /**
+     * Adjust some Stroke/Grid parameters for <i>plot</i>. The chart
+     * <i>Settings</i> are applied in this method.
+     *
+     * @param plot
+     *            The XYPlot which is adapted.
+     */
+    protected void adjustPlot(final XYPlot plot) {
+        final Stroke gridStroke = new BasicStroke(DEFAULT_GRID_LINE_WIDTH, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 3.0f, new float[] { 3.0f }, 0.0f);
+
+        final ChartSettings cs = getChartSettings();
+        final boolean isGridVisible = cs != null ? isGridVisible(cs) : true;
+
+        plot.setDomainGridlineStroke(gridStroke);
+        plot.setDomainGridlinePaint(DEFAULT_GRID_COLOR);
+        plot.setDomainGridlinesVisible(isGridVisible);
+
+        plot.setRangeGridlineStroke(gridStroke);
+        plot.setRangeGridlinePaint(DEFAULT_GRID_COLOR);
+        plot.setRangeGridlinesVisible(isGridVisible);
+
+        plot.setAxisOffset(new RectangleInsets(0d, 0d, 0d, 0d));
+    }
+
+    /**
+     * This helper method is used to extract the current locale from instance variable <i>context</i>.
+     *
+     * @return the current locale.
+     */
+    protected final Locale getLocale() {
+        final CallMeta meta = this.context.getMeta();
+        final PreferredLocale[] prefs = meta.getLanguages();
+
+        final int len = prefs != null ? prefs.length : 0;
+
+        final Locale[] locales = new Locale[len];
+
+        for (int i = 0; i < len; i++) {
+            locales[i] = prefs[i].getLocale();
+        }
+
+        return meta.getPreferredLocale(locales);
+    }
+
+    /**
+     * Look up \param key in i18n dictionary.
+     *
+     * @param key
+     *            key for which to find i18nd version.
+     * @param def
+     *            default, returned if lookup failed.
+     * @return value found in i18n dictionary, \param def if no value found.
+     */
+    public final String msg(final String key, final String def) {
+        return Resources.getMsg(this.context.getMeta(), key, def);
+    }
+
+    /**
+     * Look up \param key in i18n dictionary.
+     *
+     * @param key
+     *            key for which to find i18nd version.
+     * @return value found in i18n dictionary, key itself if failed.
+     */
+    public final String msg(final String key) {
+        return Resources.getMsg(this.context.getMeta(), key, key);
+    }
+
+    public final String msg(final String key, final String def, final Object[] args) {
+        return Resources.getMsg(this.context.getMeta(), key, def, args);
+    }
+
+    /**
+     * Add datasets stored in instance variable <i>datasets</i> to plot.
+     * <i>datasets</i> actually stores instances of AxisDataset, so each of this
+     * datasets is mapped to a specific axis as well.
+     *
+     * @param plot
+     *            plot to add datasets to.
+     */
+    protected void addDatasets(final XYPlot plot) {
+        log.debug("addDatasets()");
+
+        // AxisDatasets are sorted, but some might be empty.
+        // Thus, generate numbering on the fly.
+        int axisIndex = 0;
+        int datasetIndex = 0;
+
+        for (final Map.Entry<Integer, AxisDataset> entry : this.datasets.entrySet()) {
+            if (!entry.getValue().isEmpty()) {
+                // Add axis and range information.
+                final AxisDataset axisDataset = entry.getValue();
+                final NumberAxis axis = createYAxis(entry.getKey());
+
+                plot.setRangeAxis(axisIndex, axis);
+
+                if (axis.getAutoRangeIncludesZero()) {
+                    axisDataset.setRange(Range.expandToInclude(axisDataset.getRange(), 0d));
+                }
+
+                setYBounds(axisIndex, expandPointRange(axisDataset.getRange()));
+
+                // Add contained datasets, mapping to axis.
+                for (final XYDataset dataset : axisDataset.getDatasets()) {
+                    try {
+                        plot.setDataset(datasetIndex, dataset);
+                        plot.mapDatasetToRangeAxis(datasetIndex, axisIndex);
+
+                        applyThemes(plot, dataset, datasetIndex, axisDataset.isArea(dataset));
+
+                        datasetIndex++;
+                    }
+                    catch (final RuntimeException re) {
+                        log.error(re);
+                    }
+                }
+
+                axisDataset.setPlotAxisIndex(axisIndex);
+                axisIndex++;
+            }
+        }
+    }
+
+    /**
+     * Create Y (range) axis for given index.
+     * Shall be implemented by subclasses.
+     */
+    protected abstract NumberAxis createYAxis(final int index);
+
+    /**
+     * @param idx
+     *            "index" of dataset/series (first dataset to be drawn has
+     *            index 0), correlates with renderer index.
+     * @param isArea
+     *            true if the series describes an area and shall be rendered
+     *            as such.
+     */
+    private void applyThemes(final XYPlot plot, final XYDataset series, final int idx, final boolean isArea) {
+        if (isArea) {
+            applyAreaTheme(plot, (StyledAreaSeriesCollection) series, idx);
+        } else {
+            applyLineTheme(plot, series, idx);
+        }
+    }
+
+    /**
+     * Expands a given range if it collapses into one point.
+     *
+     * @param range
+     *            Range to be expanded if upper == lower bound.
+     *
+     * @return Bounds of point plus 5 percent in each direction.
+     */
+    private Bounds expandPointRange(final Range range) {
+        if (range == null) {
+            return null;
+        } else if (range.getLowerBound() == range.getUpperBound()) {
+            final Range expandedRange = ChartHelper.expandRange(range, 5d);
+            return new DoubleBounds(expandedRange.getLowerBound(), expandedRange.getUpperBound());
+        }
+
+        return new DoubleBounds(range.getLowerBound(), range.getUpperBound());
+    }
+
+    /**
+     * Creates a new instance of EnhancedLineAndShapeRenderer.
+     *
+     * @param plot
+     *            The plot which is set for the new renderer.
+     * @param idx
+     *            This value is not used in the current implementation.
+     *
+     * @return a new instance of EnhancedLineAndShapeRenderer.
+     */
+    private XYLineAndShapeRenderer createRenderer(final XYPlot plot, final int idx) {
+        log.debug("Create EnhancedLineAndShapeRenderer for idx: " + idx);
+
+        final EnhancedLineAndShapeRenderer r = new EnhancedLineAndShapeRenderer(true, false);
+
+        r.setPlot(plot);
+
+        return r;
+    }
+
+    /**
+     * This method applies the themes defined in the series itself. Therefore,
+     * <i>StyledXYSeries.applyTheme()</i> is called, which modifies the renderer
+     * for the series.
+     *
+     * @param plot
+     *            The plot.
+     * @param dataset
+     *            The XYDataset which needs to support Series objects.
+     * @param idx
+     *            The index of the renderer / dataset.
+     */
+    private void applyLineTheme(final XYPlot plot, final XYDataset dataset, final int idx) {
+        log.debug("Apply LineTheme for dataset at index: " + idx);
+
+        final LegendItemCollection lic = new LegendItemCollection();
+        final LegendItemCollection anno = plot.getFixedLegendItems();
+
+        final Font legendFont = createLegendLabelFont();
+
+        final XYLineAndShapeRenderer renderer = createRenderer(plot, idx);
+
+        for (int s = 0, num = dataset.getSeriesCount(); s < num; s++) {
+            final Series series = getSeriesOf(dataset, s);
+
+            if (series instanceof StyledSeries) {
+                final Style style = ((StyledSeries) series).getStyle();
+                style.applyTheme(renderer, s);
+            }
+
+            // special case: if there is just one single item, we need to enable
+            // points for this series, otherwise we would not see anything in
+            // the chart area.
+            if (series.getItemCount() == 1) {
+                renderer.setSeriesShapesVisible(s, true);
+            }
+
+            LegendItem legendItem = renderer.getLegendItem(idx, s);
+            if (legendItem.getLabel().endsWith(" ") || legendItem.getLabel().endsWith("interpol")) {
+                legendItem = null;
+            }
+
+            if (legendItem != null) {
+                legendItem.setLabelFont(legendFont);
+                lic.add(legendItem);
+            } else {
+                log.warn("Could not get LegentItem for renderer: " + idx + ", series-idx " + s);
+            }
+        }
+
+        if (anno != null) {
+            lic.addAll(anno);
+        }
+
+        plot.setFixedLegendItems(lic);
+
+        plot.setRenderer(idx, renderer);
+    }
+
+    /**
+     * @param plot
+     *            The plot.
+     * @param area
+     *            A StyledAreaSeriesCollection object.
+     * @param idx
+     *            The index of the dataset.
+     */
+    private final void applyAreaTheme(final XYPlot plot, final StyledAreaSeriesCollection area, final int idx) {
+        final LegendItemCollection lic = new LegendItemCollection();
+        final LegendItemCollection anno = plot.getFixedLegendItems();
+
+        final Font legendFont = createLegendLabelFont();
+
+        log.debug("Registering an 'area'renderer at idx: " + idx);
+
+        final StableXYDifferenceRenderer dRenderer = new StableXYDifferenceRenderer();
+
+        if (area.getMode() == StyledAreaSeriesCollection.FILL_MODE.UNDER) {
+            dRenderer.setPositivePaint(createTransparentPaint());
+        }
+
+        plot.setRenderer(idx, dRenderer);
+
+        area.applyTheme(dRenderer);
+
+        // i18n
+        dRenderer.setAreaLabelNumberFormat(Formatter.getFormatter(this.context.getMeta(), 2, 4));
+
+        dRenderer.setAreaLabelTemplate(Resources.getMsg(this.context.getMeta(), "area.label.template", "Area=%sm2"));
+
+        final LegendItem legendItem = dRenderer.getLegendItem(idx, 0);
+        if (legendItem != null) {
+            legendItem.setLabelFont(legendFont);
+            lic.add(legendItem);
+        } else {
+            log.warn("Could not get LegentItem for renderer: " + idx + ", series-idx " + 0);
+        }
+
+        if (anno != null) {
+            lic.addAll(anno);
+        }
+
+        plot.setFixedLegendItems(lic);
+    }
+
+    /**
+     * Returns a transparently textured paint.
+     *
+     * @return a transparently textured paint.
+     */
+    private static Paint createTransparentPaint() {
+        // TODO why not use a transparent color?
+        final BufferedImage texture = new BufferedImage(1, 1, BufferedImage.TYPE_4BYTE_ABGR);
+
+        return new TexturePaint(texture, new Rectangle2D.Double(0d, 0d, 0d, 0d));
+    }
+
+    private void preparePDFContext(final CallContext context) {
+        final int[] dimension = getExportDimension();
+
+        context.putContextValue("chart.width", dimension[0]);
+        context.putContextValue("chart.height", dimension[1]);
+        context.putContextValue("chart.marginLeft", 5f);
+        context.putContextValue("chart.marginRight", 5f);
+        context.putContextValue("chart.marginTop", 5f);
+        context.putContextValue("chart.marginBottom", 5f);
+        context.putContextValue("chart.page.format", ChartExportHelper.DEFAULT_PAGE_SIZE);
+    }
+
+    private void prepareSVGContext(final CallContext context) {
+        final int[] dimension = getExportDimension();
+
+        context.putContextValue("chart.width", dimension[0]);
+        context.putContextValue("chart.height", dimension[1]);
+        context.putContextValue("chart.encoding", ChartExportHelper.DEFAULT_ENCODING);
+    }
+
+    /**
+     * This method retrieves the chart subtitle by calling getChartSubtitle()
+     * and adds it as TextTitle to the chart.
+     * The default implementation of getChartSubtitle() returns the same
+     * as getDefaultChartSubtitle() which must be implemented by derived
+     * classes. If you want to add multiple subtitles to the chart override
+     * this method and add your subtitles manually.
+     *
+     * @param chart
+     *            The JFreeChart chart object.
+     */
+    protected void addSubtitles(final CallContext context, final JFreeChart chart) {
+        final String subtitle = getChartSubtitle(this.context);
+
+        if (subtitle != null && subtitle.length() > 0) {
+            chart.addSubtitle(new TextTitle(subtitle));
+        }
+    }
+
+    protected abstract String getChartSubtitle(CallContext context);
+
+    /**
+     * Adds a new AxisDataset which contains <i>dataset</i> at index <i>idx</i>.
+     *
+     * @param dataset
+     *            An XYDataset.
+     * @param idx
+     *            The axis index.
+     * @param visible
+     *            Determines, if the dataset should be visible or not.
+     */
+    protected final void addAxisDataset(final XYDataset dataset, final int idx, final boolean visible) {
+        if (dataset == null || idx < 0) {
+            return;
+        }
+
+        final AxisDataset axisDataset = getAxisDataset(idx);
+
+        final Bounds[] xyBounds = ChartHelper.getBounds(dataset);
+
+        if (xyBounds == null) {
+            log.warn("Skip XYDataset for Axis (invalid ranges): " + idx);
+            return;
+        }
+
+        if (visible) {
+            if (log.isDebugEnabled()) {
+                log.debug("Add new AxisDataset at index: " + idx);
+                log.debug("X extent: " + xyBounds[0]);
+                log.debug("Y extent: " + xyBounds[1]);
+            }
+
+            axisDataset.addDataset(dataset);
+        }
+
+        combineXBounds(xyBounds[0], 0);
+        combineYBounds(xyBounds[1], idx);
+    }
+
+    /**
+     * This method grants access to the AxisDatasets stored in <i>datasets</i>.
+     * If no AxisDataset exists for index <i>idx</i>, a new AxisDataset is
+     * created using <i>createAxisDataset()</i>.
+     *
+     * @param idx
+     *            The index of the desired AxisDataset.
+     *
+     * @return an existing or new AxisDataset.
+     */
+    protected final AxisDataset getAxisDataset(final int idx) {
+        AxisDataset axisDataset = this.datasets.get(idx);
+
+        if (axisDataset == null) {
+            axisDataset = createAxisDataset(idx);
+            this.datasets.put(idx, axisDataset);
+        }
+
+        return axisDataset;
+    }
+
+    /**
+     * Returns the size of a chart export as array which has been specified by
+     * the incoming request document.
+     *
+     * @return the size of a chart as [width, height] or null if no width or
+     *         height are given in the request document.
+     */
+    protected final int[] getSize() {
+        final int[] size = new int[2];
+
+        final Element sizeEl = (Element) XMLUtils.xpath(this.request, XPATH_CHART_SIZE, XPathConstants.NODE, ArtifactNamespaceContext.INSTANCE);
+
+        if (sizeEl != null) {
+            final String uri = ArtifactNamespaceContext.NAMESPACE_URI;
+
+            final String w = sizeEl.getAttributeNS(uri, "width");
+            final String h = sizeEl.getAttributeNS(uri, "height");
+
+            if (w.length() > 0 && h.length() > 0) {
+                try {
+                    size[0] = Integer.parseInt(w);
+                    size[1] = Integer.parseInt(h);
+                }
+                catch (final NumberFormatException nfe) {
+                    log.warn("Wrong values for chart width/height.");
+                }
+            }
+        }
+
+        return size[0] > 0 && size[1] > 0 ? size : null;
+    }
+
+    /**
+     * This method returns the format specified in the <i>request</i> document
+     * or <i>DEFAULT_CHART_FORMAT</i> if no format is specified in
+     * <i>request</i>.
+     *
+     * @return the format used to export this chart.
+     */
+    private String getFormat() {
+        final String format = (String) XMLUtils.xpath(this.request, XPATH_CHART_FORMAT, XPathConstants.STRING, ArtifactNamespaceContext.INSTANCE);
+
+        return format == null || format.length() == 0 ? DEFAULT_CHART_FORMAT : format;
+    }
+
+    /**
+     * Returns the X-Axis range as String array from request document.
+     * If the (x|y)range elements are not found in request document, return
+     * null (i.e. not zoomed).
+     *
+     * @return a String array with [lower, upper], null if not in document.
+     */
+    protected final String[] getDomainAxisRangeFromRequest() {
+        final Element xrange = (Element) XMLUtils.xpath(this.request, XPATH_CHART_X_RANGE, XPathConstants.NODE, ArtifactNamespaceContext.INSTANCE);
+
+        if (xrange == null) {
+            return null;
+        }
+
+        final String uri = ArtifactNamespaceContext.NAMESPACE_URI;
+
+        final String lower = xrange.getAttributeNS(uri, "from");
+        final String upper = xrange.getAttributeNS(uri, "to");
+
+        return new String[] { lower, upper };
+    }
+
+    /**
+     * Returns null if the (x|y)range-element was not found in
+     * request document.
+     * This usally means that the axis are not manually zoomed, i.e. showing
+     * full data extent.
+     */
+    protected final String[] getValueAxisRangeFromRequest() {
+        final Element yrange = (Element) XMLUtils.xpath(this.request, XPATH_CHART_Y_RANGE, XPathConstants.NODE, ArtifactNamespaceContext.INSTANCE);
+
+        if (yrange == null) {
+            return null;
+        }
+
+        final String uri = ArtifactNamespaceContext.NAMESPACE_URI;
+
+        final String lower = yrange.getAttributeNS(uri, "from");
+        final String upper = yrange.getAttributeNS(uri, "to");
+
+        return new String[] { lower, upper };
+    }
+
+    /**
+     * Returns the default size of a chart export as array.
+     *
+     * @return the default size of a chart as [width, height].
+     */
+    protected final int[] getDefaultSize() {
+        return new int[] { DEFAULT_CHART_WIDTH, DEFAULT_CHART_HEIGHT };
+    }
+
+    /**
+     * This method returns the export dimension specified in ChartSettings as
+     * int array [width,height].
+     *
+     * @return an int array with [width,height].
+     */
+    private int[] getExportDimension() {
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings == null)
+            return new int[] { DEFAULT_CHART_WIDTH, DEFAULT_CHART_HEIGHT };
+
+        final ExportSection export = chartSettings.getExportSection();
+        final Integer width = export.getWidth();
+        final Integer height = export.getHeight();
+
+        if (width != null && height != null) {
+            return new int[] { width, height };
+        }
+
+        return new int[] { 600, 400 };
+    }
+
+    /**
+     * Returns the chart title provided by <i>settings</i>.
+     *
+     * @param settings
+     *            A ChartSettings object.
+     *
+     * @return the title provided by <i>settings</i> or null if no
+     *         <i>ChartSection</i> is provided by <i>settings</i>.
+     *
+     * @throws NullPointerException
+     *             if <i>settings</i> is null.
+     */
+    private String getChartTitle(final ChartSettings settings) {
+        final ChartSection cs = settings.getChartSection();
+        return cs != null ? cs.getTitle() : null;
+    }
+
+    /**
+     * Returns the chart subtitle provided by <i>settings</i>.
+     *
+     * @param settings
+     *            A ChartSettings object.
+     *
+     * @return the subtitle provided by <i>settings</i> or null if no
+     *         <i>ChartSection</i> is provided by <i>settings</i>.
+     *
+     * @throws NullPointerException
+     *             if <i>settings</i> is null.
+     */
+    protected final String getChartSubtitle(final ChartSettings settings) {
+        final ChartSection cs = settings.getChartSection();
+        return cs != null ? cs.getSubtitle() : null;
+    }
+
+    /**
+     * Returns the title of a chart. The return value depends on the existence
+     * of ChartSettings: if there are ChartSettings set, this method returns the
+     * chart title provided by those settings. Otherwise, this method returns
+     * getDefaultChartTitle().
+     *
+     * @return the title of a chart.
+     */
+    protected String getChartTitle(final CallContext context) {
+        final ChartSettings chartSettings = getChartSettings();
+
+        if (chartSettings != null) {
+            return getChartTitle(chartSettings);
+        }
+
+        return getDefaultChartTitle(context);
+    }
+
+    /**
+     * This method always returns null. Override it in subclasses that require
+     * subtitles.
+     *
+     * @return null.
+     */
+    protected String getDefaultChartSubtitle(final CallContext context) {
+        // Override this method in subclasses
+        return null;
+    }
+
+    /** Where to place the logo. */
+    protected final String logoHPlace() {
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings != null) {
+            final ChartSection cs = chartSettings.getChartSection();
+            final String place = cs.getLogoHPlacement();
+
+            return place;
+        }
+        return "center";
+    }
+
+    /** Where to place the logo. */
+    protected final String logoVPlace() {
+        final ChartSettings chartSettings = getChartSettings();
+        if (chartSettings != null) {
+            final ChartSection cs = chartSettings.getChartSection();
+            final String place = cs.getLogoVPlacement();
+
+            return place;
+        }
+        return "top";
+    }
+
+    /** Return the logo id from settings. */
+    private String showLogo(final ChartSettings chartSettings) {
+        if (chartSettings != null) {
+            final ChartSection cs = chartSettings.getChartSection();
+            final String logo = cs.getDisplayLogo();
+
+            return logo;
+        }
+        return "none";
+    }
+
+    /**
+     * This method is used to determine if a logo should be added to the plot.
+     *
+     * @return logo name (null if none).
+     */
+    protected final String showLogo() {
+        final ChartSettings chartSettings = getChartSettings();
+        return showLogo(chartSettings);
+    }
+
+    /**
+     * This method is used to determine if the resulting chart should display
+     * grid lines or not. <b>Note: this method always returns true!</b>
+     *
+     * @return true, if the chart should display grid lines, otherwise false.
+     */
+    protected final boolean isGridVisible() {
+        return true;
+    }
+
+    protected final void addAnnotationsToRenderer(final XYPlot plot) {
+
+        final AnnotationRenderer annotationRenderer = new AnnotationRenderer(getChartSettings(), this.datasets, DEFAULT_FONT_NAME);
+        annotationRenderer.addAnnotationsToRenderer(plot, this.annotations);
+
+        doAddFurtherAnnotations(plot, this.annotations);
+    }
+
+    /**
+     * Allow further annotation processing, override to implement.
+     *
+     * Does nothing by default.
+     */
+    protected void doAddFurtherAnnotations(final XYPlot plot, final List<RiverAnnotation> annotations) {
+
+    }
 }
\ No newline at end of file

http://dive4elements.wald.intevation.org