view flys-artifacts/src/main/java/de/intevation/flys/exports/TimeseriesChartGenerator.java @ 3227:ed07dd55f487

Fixed various bugs (package declarations, moved classes to correct places). flys-artifacts/trunk@4854 c6561f87-3c4e-4783-a992-168aeb5c3f6f
author Ingo Weinzierl <ingo.weinzierl@intevation.de>
date Tue, 03 Jul 2012 08:46:14 +0000
parents 3287019ad04b
children b0eee4c1eaa0
line wrap: on
line source
package de.intevation.flys.exports;

import de.intevation.artifactdatabase.state.ArtifactAndFacet;
import de.intevation.artifactdatabase.state.Facet;

import de.intevation.flys.jfree.Bounds;
import de.intevation.flys.jfree.CollisionFreeXYTextAnnotation;
import de.intevation.flys.jfree.DoubleBounds;
import de.intevation.flys.jfree.FLYSAnnotation;
import de.intevation.flys.jfree.StyledTimeSeries;
import de.intevation.flys.jfree.TimeBounds;

import de.intevation.flys.themes.ThemeAccess;

import de.intevation.flys.artifacts.model.HYKFactory;

import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.Paint;
import java.awt.Stroke;

import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.log4j.Logger;

import org.jfree.chart.ChartFactory;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.LegendItemCollection;

import org.jfree.chart.annotations.XYBoxAnnotation;
import org.jfree.chart.annotations.XYTextAnnotation;

import org.jfree.chart.axis.ValueAxis;

import org.jfree.chart.plot.Marker;
import org.jfree.chart.plot.XYPlot;

import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;

import org.jfree.data.Range;

import org.jfree.data.general.Series;

import org.jfree.data.time.Day;
import org.jfree.data.time.TimeSeries;
import org.jfree.data.time.TimeSeriesCollection;

import org.jfree.data.xy.XYDataset;

import org.jfree.ui.Layer;

import org.json.JSONArray;
import org.json.JSONException;

import org.w3c.dom.Document;

/**
 * @author <a href="mailto:ingo.weinzierl@intevation.de">Ingo Weinzierl</a>
 */
public abstract class TimeseriesChartGenerator extends ChartGenerator {


    /**
     * Inner class TimeseriesAxisDataset stores TimeSeriesCollection.
     */
    public class TimeseriesAxisDataset implements AxisDataset {

        protected int axisSymbol;

        protected List<TimeSeriesCollection> datasets;

        protected Range range;

        protected int plotAxisIndex;

        public TimeseriesAxisDataset(int axisSymbol) {
            this.axisSymbol = axisSymbol;
            this.datasets   = new ArrayList<TimeSeriesCollection>();
        }


        @Override
        public void addDataset(XYDataset dataset) {
            if (!(dataset instanceof TimeSeriesCollection)) {
                logger.warn("Skip non TimeSeriesCollection dataset.");
                return;
            }

            TimeSeriesCollection tsc = (TimeSeriesCollection) dataset;

            datasets.add(tsc);
            mergeRanges(tsc);
        }


        @Override
        public XYDataset[] getDatasets() {
            return (XYDataset[])
                datasets.toArray(new XYDataset[datasets.size()]);
        }


        @Override
        public boolean isEmpty() {
            return datasets.isEmpty();
        }


        @Override
        public void setRange(Range range) {
            this.range = range;
        }


        @Override
        public Range getRange() {
            return range;
        }


        @Override
        public void setPlotAxisIndex(int plotAxisIndex) {
            this.plotAxisIndex = plotAxisIndex;
        }


        @Override
        public int getPlotAxisIndex() {
            return plotAxisIndex;
        }


        @Override
        public boolean isArea(XYDataset dataset) {
            logger.warn("This AxisDataset doesn't support Areas yet!");
            return false;
        }


        protected void mergeRanges(TimeSeriesCollection dataset) {
            logger.debug("Range after merging: " + range);

            Bounds[] xyRanges = ChartHelper.getBounds(dataset);

            // TODO COMBINE BOUNDS!

            logger.debug("Range after merging: " + range);
        }

    } // end of TimeseriesAxisDataset class


    /** List of annotations to insert in plot. */
    protected List<FLYSAnnotation> annotations;

    protected List<Marker> domainMarker;

    protected List<Marker> valueMarker;

    protected Map<String, String> attributes;

    protected boolean domainZeroLineVisible;

    private static final Logger logger =
        Logger.getLogger(TimeseriesChartGenerator.class);


    public static final int AXIS_SPACE = 5;


    protected Map<Integer, Bounds> xBounds;

    protected Map<Integer, Bounds> yBounds;



    /**
     * The default constructor that initializes internal datastructures.
     */
    public TimeseriesChartGenerator() {
        super();

        xBounds = new HashMap<Integer, Bounds>();
        yBounds = new HashMap<Integer, Bounds>();
        domainMarker = new ArrayList<Marker>();
        valueMarker = new ArrayList<Marker>();
        attributes = new HashMap<String, String>();
    }



    @Override
    public JFreeChart generateChart() {
        logger.info("Generate Timeseries Chart.");

        JFreeChart chart = ChartFactory.createTimeSeriesChart(
            getChartTitle(),
            getXAxisLabel(),
            getYAxisLabel(0),
            null,
            isLegendVisible(),
            false,
            false);

        XYPlot plot = (XYPlot) chart.getPlot();

        chart.setBackgroundPaint(Color.WHITE);
        plot.setBackgroundPaint(Color.WHITE);

        addSubtitles(chart);
        adjustPlot(plot);
        addDatasets(plot);
        adjustAxes(plot);
        addDomainAxisMarker(plot);
        addValueAxisMarker(plot);
        adaptZoom(plot);

        applySeriesAttributes(plot);
        addAnnotationsToRenderer(plot);
        aggregateLegendEntries(plot);
        return chart;
    }


    @Override
    protected Series getSeriesOf(XYDataset dataset, int idx) {
        return ((TimeSeriesCollection) dataset).getSeries(idx);
    }


    /**
     * This method creates new instances of TimeseriesAxisDataset.
     *
     * @param idx The symbol for the new TimeseriesAxisDataset.
     */
    @Override
    protected AxisDataset createAxisDataset(int idx) {
        logger.debug("Create a new AxisDataset for index: " + idx);
        return new TimeseriesAxisDataset(idx);
    }


    @Override
    protected void combineXBounds(Bounds bounds, int index) {
        if (bounds != null) {
            Bounds old = getXBounds(index);

            if (old != null) {
                bounds = bounds.combine(old);
            }

            setXBounds(index, bounds);
        }
    }


    @Override
    protected void combineYBounds(Bounds bounds, int index) {
        if (bounds != null) {
            Bounds old = getYBounds(index);

            if (old != null) {
                bounds = bounds.combine(old);
            }

            setYBounds(index, bounds);
        }
    }


    // TODO REPLACE THIS METHOD WITH getBoundsForAxis(index)
    @Override
    public Range[] getRangesForAxis(int index) {
        // TODO
        Bounds[] bounds = getBoundsForAxis(index);

        return new Range[] {
            new Range(
                bounds[0].getLower().doubleValue(),
                bounds[0].getUpper().doubleValue()),
            new Range(
                bounds[1].getLower().doubleValue(),
                bounds[1].getUpper().doubleValue())
        };
    }


    @Override
    public Bounds getXBounds(int axis) {
        return xBounds.get(axis);
    }


    @Override
    protected void setXBounds(int axis, Bounds bounds) {
        xBounds.put(axis, bounds);
    }


    @Override
    public Bounds getYBounds(int axis) {
        return yBounds.get(axis);
    }


    @Override
    protected void setYBounds(int axis, Bounds bounds) {
        if (bounds != null) {
            yBounds.put(axis, bounds);
        }
    }


    public Bounds[] getBoundsForAxis(int index) {
        logger.debug("Return x and y bounds for axis at: " + index);

        Bounds rx = getXBounds(Integer.valueOf(index));
        Bounds ry = getYBounds(Integer.valueOf(index));

        if (rx == null) {
            logger.warn("Range for x axis not set." +
                        " Using default values: 0 - 1.");
            rx = new TimeBounds(0l, 1l);
        }

        if (ry == null) {
            logger.warn("Range for y axis not set." +
                        " Using default values: 0 - 1.");
            ry = new DoubleBounds(0l, 1l);
        }

        logger.debug("X Bounds at index " + index + " is: " + rx);
        logger.debug("Y Bounds at index " + index + " is: " + ry);

        return new Bounds[] {rx, ry};
    }


    public Bounds getDomainAxisRange() {
        String[] ranges = getDomainAxisRangeFromRequest();

        if (ranges == null || ranges.length < 2) {
            logger.debug("No zoom range for domain axis specified.");
            return null;
        }

        if (ranges[0] == null || ranges[1] == null) {
            logger.warn("Invalid ranges for domain axis specified!");
            return null;
        }

        try {
            double lower = Double.parseDouble(ranges[0]);
            double upper = Double.parseDouble(ranges[1]);

            return new DoubleBounds(lower, upper);
        }
        catch (NumberFormatException nfe) {
            logger.warn("Invalid ranges for domain axis specified: " + nfe);
        }

        return null;
    }


    public Bounds getValueAxisRange() {
        String[] ranges = getValueAxisRangeFromRequest();

        if (ranges == null || ranges.length < 2) {
            logger.debug("No zoom range for domain axis specified.");
            return null;
        }

        if (ranges[0] == null || ranges[1] == null) {
            logger.warn("Invalid ranges for domain axis specified!");
            return null;
        }

        try {
            double lower = Double.parseDouble(ranges[0]);
            double upper = Double.parseDouble(ranges[1]);

            return new DoubleBounds(lower, upper);
        }
        catch (NumberFormatException nfe) {
            logger.warn("Invalid ranges for domain axis specified: " + nfe);
        }

        return null;
    }


    protected void adaptZoom(XYPlot plot) {
        logger.debug("Adapt zoom of Timeseries chart.");

        zoomX(plot, plot.getDomainAxis(), getXBounds(0), getDomainAxisRange());

        Bounds valueAxisBounds = getValueAxisRange();

        for (int j = 0, n = plot.getRangeAxisCount(); j < n; j++) {
            zoomY(
                plot,
                plot.getRangeAxis(j),
                getYBounds(j),
                valueAxisBounds);
        }
    }


    protected void zoomX(
        XYPlot    plot,
        ValueAxis axis,
        Bounds    total,
        Bounds    user
    ) {
        if (logger.isDebugEnabled()) {
            logger.debug("== Zoom X axis ==");
            logger.debug("    Total axis range  : " + total);
            logger.debug("    User defined range: " + user);
        }

        if (user != null) {
            long min  = total.getLower().longValue();
            long max  = total.getUpper().longValue();
            long diff = max > min ? max - min : min - max;

            long newMin = (long) Math.round(min + user.getLower().doubleValue() * diff);
            long newMax = (long) Math.round(min + user.getUpper().doubleValue() * diff);

            TimeBounds newBounds = new TimeBounds(newMin, newMax);

            logger.debug("    Zoom axis to: " + newBounds);

            newBounds.applyBounds(axis, AXIS_SPACE);
        }
        else {
            logger.debug("No user specified zoom values found!");
            if (total != null && axis != null) {
                total.applyBounds(axis, AXIS_SPACE);
            }
        }
    }


    protected void zoomY(
        XYPlot    plot,
        ValueAxis axis,
        Bounds    total,
        Bounds    user
    ) {
        if (logger.isDebugEnabled()) {
            logger.debug("== Zoom Y axis ==");
            logger.debug("    Total axis range  : " + total);
            logger.debug("    User defined range: " + user);
        }

        if (user != null) {
            double min  = total.getLower().doubleValue();
            double max  = total.getUpper().doubleValue();
            double diff = max > min ? max - min : min - max;

            double newMin = min + user.getLower().doubleValue() * diff;
            double newMax = min + user.getUpper().doubleValue() * diff;

            DoubleBounds newBounds = new DoubleBounds(newMin, newMax);

            logger.debug("    Zoom axis to: " + newBounds);

            newBounds.applyBounds(axis, AXIS_SPACE);
        }
        else {
            logger.debug("No user specified zoom values found!");
            if (total != null && axis != null) {
                total.applyBounds(axis, AXIS_SPACE);
            }
        }
    }


    /**
     * Adjusts the axes of a plot. This method sets the <i>labelFont</i> of the
     * X axis.
     *
     * @param plot The XYPlot of the chart.
     */
    protected void adjustAxes(XYPlot plot) {
        ValueAxis xaxis = plot.getDomainAxis();

        ChartSettings chartSettings = getChartSettings();
        if (chartSettings == null) {
            return;
        }

        Font labelFont = new Font(
            DEFAULT_FONT_NAME,
            Font.BOLD,
            getXAxisLabelFontSize());

        xaxis.setLabelFont(labelFont);
        xaxis.setTickLabelFont(labelFont);
    }


    /**
     * Do Points out.
     */
    protected void doPoints(
        Object     o,
        ArtifactAndFacet aandf,
        Document   theme,
        boolean    visible,
        int        axisIndex
    ) {
        String seriesName = aandf.getFacetDescription();
        TimeSeries series = new StyledTimeSeries(seriesName, theme);

        // Add text annotations for single points.
        List<XYTextAnnotation> xy = new ArrayList<XYTextAnnotation>();
        HashMap<Day, String> names = new HashMap<Day, String>();

        try {
            JSONArray points = new JSONArray((String) o);
            for (int i = 0, P = points.length(); i < P; i++) {
                JSONArray array = points.getJSONArray(i);
                double x    = array.getDouble(0);
                double y    = array.getDouble(1);
                String name = array.getString(2);
                boolean act = array.getBoolean(3);
                if (!act) {
                    continue;
                }
                long l = (new Double(x)).longValue();
                Date date = new Date(l);
                Day day = new Day(date);
                series.add(day, y, false);
                names.put(day, name);
            }
        }
        catch(JSONException e){
            logger.error("Could not decode json.");
        }

        TimeSeriesCollection tsc = new TimeSeriesCollection();
        tsc.addSeries(series);
        // Add Annotations.
        for (int i = 0, S = series.getItemCount(); i < S; i++) {
            double x = tsc.getXValue(0, i);
            double y = tsc.getYValue(0, i);
            xy.add(new CollisionFreeXYTextAnnotation(
                       names.get(series.getTimePeriod(i)), x, y));
        }
        FLYSAnnotation annotations =
            new FLYSAnnotation(null, null, null, theme);
        annotations.setTextAnnotations(xy);

        // Do not generate second legend entry. (null was passed for the aand before).
        doAnnotations(annotations, null, theme, visible);

        addAxisDataset(tsc, axisIndex, visible);
    }

    /**
     * Register annotations like MainValues for later plotting
     *
     * @param o     list of annotations (data of facet).
     * @param facet The facet. This facet does NOT support any data objects. Use
     * FLYSArtifact.getNativeFacet() instead to retrieve a Facet which supports
     * data.
     * @param theme   Theme document for given annotations.
     * @param visible The visibility of the annotations.
     */
    protected void doAnnotations(
        FLYSAnnotation annotations,
        ArtifactAndFacet aandf,
        Document theme,
        boolean visible
    ){
        // Running into trouble here.
        logger.debug("doAnnotations");

        // Add all annotations to our annotation pool.
        annotations.setTheme(theme);
        if (aandf != null) {
            Facet facet = aandf.getFacet();
            annotations.setLabel(aandf.getFacetDescription());
        }
        else {
            logger.debug(
                "Art/Facet for Annotations is null. " +
                "This should never happen!");
        }

        addAnnotations(annotations, visible);
    }



    /**
     * Adds annotations to list (if visible is true).
     */
    public void addAnnotations(FLYSAnnotation annotation, boolean visible) {
        if (!visible) {
            return;
        }

        if (annotations == null) {
            annotations = new ArrayList<FLYSAnnotation>();
        }

        annotations.add(annotation);
    }


    /** Add annotations (Sticky, Text and hyk zones). */
   /* public void addAnnotationsToRenderer(XYPlot plot) {
        logger.debug("addAnnotationsToRenderer");

        if (annotations == null) {
            logger.debug("addAnnotationsToRenderer: no annotations.");
            return;
        }

        // Paints for the boxes/lines.
        Stroke basicStroke = new BasicStroke(1.0f);

        Paint linePaint = new Color(255,  0,0,60);
        Paint fillPaint = new Color(0,  255,0,60);
        Paint tranPaint = new Color(0,    0,0, 0);

        // OPTMIMIZE: Pre-calculate positions
        Area area = new Area(
            plot.getDomainAxis(0).getRange(),
            plot.getRangeAxis().getRange());

        // Walk over all Annotation sets.
        for (FLYSAnnotation fa: annotations) {

            // Access text styling, if any.
            Document theme = fa.getTheme();
            ThemeAccess.TextStyle textStyle = null;
            ThemeAccess.LineStyle lineStyle = null;

            // Get Themeing information and add legend item.
            if (theme != null) {
                ThemeAccess themeAccess = new ThemeAccess(theme);
                textStyle = themeAccess.parseTextStyle();
                lineStyle = themeAccess.parseLineStyle();
                if (fa.getLabel() != null) {
                    LegendItemCollection lic = new LegendItemCollection();
                    LegendItemCollection old = plot.getFixedLegendItems();
                    lic.add(createLegendItem(theme, fa.getLabel()));
                    // (Re-)Add prior legend entries.
                    if (old != null) {
                        old.addAll(lic);
                    }
                    else {
                        old = lic;
                    }
                    plot.setFixedLegendItems(old);
                }
            }

            // Other Text Annotations (e.g. labels of manual points).
            for (XYTextAnnotation ta: fa.getTextAnnotations()) {
                // Style the text.
                if (textStyle != null) {
                    textStyle.apply(ta);
                }
                ta.setY(area.above(0.05d, ta.getY()));
                plot.getRenderer().addAnnotation(ta, org.jfree.ui.Layer.FOREGROUND);
            }
        }
    }*/

        /**
     * Add the annotations (Sticky, Text and hyk zones) stored
     * in the annotations field.
     */
    public void addAnnotationsToRenderer(XYPlot plot) {
        logger.debug("addAnnotationsToRenderer");

        if (annotations == null) {
            logger.debug("addAnnotationsToRenderer: no annotations.");
            return;
        }

        // Paints for the boxes/lines.
        Stroke basicStroke = new BasicStroke(1.0f);

        Paint linePaint = new Color(255,  0,0,60);
        Paint fillPaint = new Color(0,  255,0,60);
        Paint tranPaint = new Color(0,    0,0, 0);

        // OPTMIMIZE: Pre-calculate positions
        Area area = new Area(
            plot.getDomainAxis(0).getRange(),
            plot.getRangeAxis().getRange());

        // Walk over all Annotation sets.
        for (FLYSAnnotation fa: annotations) {

            // Access text styling, if any.
            Document theme = fa.getTheme();
            ThemeAccess.TextStyle textStyle = null;
            ThemeAccess.LineStyle lineStyle = null;

            // Get Themeing information and add legend item.
            if (theme != null) {
                ThemeAccess themeAccess = new ThemeAccess(theme);
                textStyle = themeAccess.parseTextStyle();
                lineStyle = themeAccess.parseLineStyle();
                if (fa.getLabel() != null) {
                    LegendItemCollection lic = new LegendItemCollection();
                    LegendItemCollection old = plot.getFixedLegendItems();
                    lic.add(createLegendItem(theme, fa.getLabel()));
                    // (Re-)Add prior legend entries.
                    if (old != null) {
                        old.addAll(lic);
                    }
                    else {
                        old = lic;
                    }
                    plot.setFixedLegendItems(old);
                }
            }

            // The 'Sticky' Annotations (at axis, with line and text).
    /*        for (StickyAxisAnnotation sta: fa.getAxisTextAnnotations()) {
                addStickyAnnotation(
                    sta, plot, area, lineStyle, textStyle, theme);
            }*/

            // Other Text Annotations (e.g. labels of manual points).
            for (XYTextAnnotation ta: fa.getTextAnnotations()) {
                // Style the text.
                if (textStyle != null) {
                    textStyle.apply(ta);
                }
                ta.setY(area.above(0.05d, ta.getY()));
                plot.getRenderer().addAnnotation(ta, org.jfree.ui.Layer.FOREGROUND);
            }

            // Hyks.
            for (HYKFactory.Zone zone: fa.getBoxes()) {
                // For each zone, create a box to fill with color, a box to draw
                // the lines and a text to display the type.
                fillPaint = colorForHYKZone(zone.getName());

                XYBoxAnnotation boxA = new XYBoxAnnotation(zone.getFrom(), area.atGround(),
                    zone.getTo(), area.ofGround(0.03f), basicStroke, tranPaint, fillPaint);
                XYBoxAnnotation boxB = new XYBoxAnnotation(zone.getFrom(), area.atGround(),
                    zone.getTo(), area.atTop(), basicStroke, fillPaint, tranPaint);

                XYTextAnnotation tex = new XYTextAnnotation(zone.getName(),
                    zone.getFrom() + (zone.getTo() - zone.getFrom()) / 1.0d,
                    area.ofGround(0.015f));
                if (textStyle != null) {
                    textStyle.apply(tex);
                }

                plot.getRenderer().addAnnotation(boxA, org.jfree.ui.Layer.BACKGROUND);
                plot.getRenderer().addAnnotation(boxB, org.jfree.ui.Layer.BACKGROUND);
                plot.getRenderer().addAnnotation(tex,  org.jfree.ui.Layer.BACKGROUND);
            }
        }
    }

        /** Get color for hyk zones by their type (which is the name). */
    public Paint colorForHYKZone(String zoneName) {
        if (zoneName.startsWith("R")) {
            // Brownish.
            return new Color(153, 60, 0);
        }
        else if (zoneName.startsWith("V")) {
            // Greenish.
            return new Color(0, 255, 0);
        }
        else if (zoneName.startsWith("B")) {
            // Grayish.
            return new Color(128, 128, 128);
        }
        else if (zoneName.startsWith("H")) {
            // Blueish.
            return new Color(0, 0, 255);
        }
        else {
            // Default.
            logger.debug("Unknown zone type found.");
            return new Color(255, 0, 0);
        }
    }

    public void addDomainAxisMarker(XYPlot plot) {
        logger.debug("domainmarkers: " + domainMarker.size());
        for (Marker marker: domainMarker) {
            logger.debug("adding domain marker");
            plot.addDomainMarker(marker, Layer.BACKGROUND);
        }
        domainMarker.clear();
    }

    public void addValueAxisMarker(XYPlot plot) {
        for (Marker marker: valueMarker) {
            logger.debug("adding value marker..");
            plot.addRangeMarker(marker, Layer.BACKGROUND);
        }
        valueMarker.clear();
    }

    public void addAttribute(String seriesKey, String name) {
        attributes.put(seriesKey, name);
    }

    protected void applySeriesAttributes(XYPlot plot) {
        int count  = plot.getDatasetCount();
        for (int i = 0; i < count; i++) {
            XYDataset data = plot.getDataset(i);
            if (data == null) {
                continue;
            }
            int seriesCount = data.getSeriesCount();
            for (int j = 0; j < seriesCount; j++) {
                StyledTimeSeries series =
                    (StyledTimeSeries)getSeriesOf(data, j);
                String key = series.getKey().toString();
                if (attributes.containsKey(key)) {
                    if (attributes.get(key).equals("interpolate")) {
                        XYLineAndShapeRenderer renderer =
                            series.getStyle().getRenderer();
                        renderer.setSeriesPaint(
                            j,
                            renderer.getSeriesFillPaint(j));
                        renderer.setSeriesShapesFilled(j, false);
                    }
                }
                if (attributes.containsKey(key)) {
                    if(attributes.get(key).equals("outline")) {
                        XYLineAndShapeRenderer renderer =
                            series.getStyle().getRenderer();
                        renderer.setSeriesPaint(
                            j,
                            renderer.getSeriesFillPaint(j));
                        renderer.setDrawOutlines(true);
                    }
                }
            }
        }
    }

    /** Two Ranges that span a rectangular area. */
    public static class Area {
        protected Range xRange;
        protected Range yRange;

        public Area(Range rangeX, Range rangeY) {
            this.xRange = rangeX;
            this.yRange = rangeY;
        }

        public Area(ValueAxis axisX, ValueAxis axisY) {
            this.xRange = axisX.getRange();
            this.yRange = axisY.getRange();
        }

        public double ofLeft(double percent) {
            return xRange.getLowerBound()
                + xRange.getLength() * percent;
        }

        public double ofRight(double percent) {
            return xRange.getUpperBound()
                - xRange.getLength() * percent;
        }

        public double ofGround(double percent) {
            return yRange.getLowerBound()
                + yRange.getLength() * percent;
        }

        public double atTop() {
            return yRange.getUpperBound();
        }

        public double atGround() {
            return yRange.getLowerBound();
        }

        public double atRight() {
            return xRange.getUpperBound();
        }

        public double atLeft() {
            return xRange.getLowerBound();
        }

        public double above(double percent, double base) {
            return base + yRange.getLength() * percent;
        }
    }

}
// vim:set ts=4 sw=4 si et sta sts=4 fenc=utf8 :

http://dive4elements.wald.intevation.org