view artifacts/src/main/java/org/dive4elements/river/exports/TimeseriesChartGenerator.java @ 6611:dfdeed3e997e

Shorten and correct waterlevel not in gauge string
author Andre Heinecke <aheinecke@intevation.de>
date Thu, 18 Jul 2013 17:54:44 +0200
parents af13ceeba52a
children 0747ca95ad6e
line wrap: on
line source
/* Copyright (C) 2011, 2012, 2013 by Bundesanstalt für Gewässerkunde
 * Software engineering by Intevation GmbH
 *
 * This file is Free Software under the GNU AGPL (>=v3)
 * and comes with ABSOLUTELY NO WARRANTY! Check out the
 * documentation coming with Dive4Elements River for details.
 */

package org.dive4elements.river.exports;

import org.dive4elements.artifactdatabase.state.ArtifactAndFacet;
import org.dive4elements.river.artifacts.resources.Resources;
import org.dive4elements.river.jfree.Bounds;
import org.dive4elements.river.jfree.CollisionFreeXYTextAnnotation;
import org.dive4elements.river.jfree.DoubleBounds;
import org.dive4elements.river.jfree.RiverAnnotation;
import org.dive4elements.river.jfree.StyledTimeSeries;
import org.dive4elements.river.jfree.TimeBounds;

import java.awt.Color;
import java.awt.Font;
import java.text.DateFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.swing.ImageIcon;

import org.apache.log4j.Logger;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.LegendItem;
import org.jfree.chart.LegendItemCollection;
import org.jfree.chart.annotations.XYAnnotation;
import org.jfree.chart.annotations.XYImageAnnotation;
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 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 before merging: " + range);
            Range subRange = null;

            // Determine min/max of range axis.
            for (int i = 0; i < dataset.getSeriesCount(); i++) {
                if (dataset.getSeries(i).getItemCount() == 0) {
                    continue;
                }
                double min = Double.MAX_VALUE;
                double max = -Double.MAX_VALUE;
                TimeSeries series = dataset.getSeries(i);
                for (int j = 0; j < series.getItemCount(); j++) {
                    double tmp = series.getValue(j).doubleValue();
                    min = tmp < min ? tmp : min;
                    max = tmp > max ? tmp : max;
                }
                if (subRange != null) {
                    subRange = new Range(
                        min < subRange.getLowerBound() ?
                            min : subRange.getLowerBound(),
                        max > subRange.getUpperBound() ?
                            max : subRange.getUpperBound());
                }
                else {
                    subRange = new Range(min, max);
                }
            }

            // Avoid merging NaNs, as they take min/max place forever.
            if (subRange == null ||
                Double.isNaN(subRange.getLowerBound()) ||
                Double.isNaN(subRange.getUpperBound())) {
                return;
            }
            if (range == null) {
                range = subRange;
                return;
            }
            range = Range.combine(range, subRange);
        }

    } // end of TimeseriesAxisDataset class

    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);
        addLogo(plot);
        aggregateLegendEntries(plot);
        return chart;
    }


    /**
     * Return left most data points x value (on first axis).
     * Shortcut, especially to be overridden in (LS) charts where
     * axis could be inverted.
     */
    protected double getLeftX() {
        return (Long)getXBounds(0).getLower();
    }


    /**
     * Return right most data points x value (on first axis).
     * Shortcut, especially to be overridden in (LS) charts where
     * axis could be inverted.
     */
    protected double getRightX() {
        return (Long)getXBounds(0).getUpper();
    }


    /**
     * Add a logo as background annotation to plot.
     * Copy from XYChartGenerator.
     */
    protected void addLogo(XYPlot plot) {
        String logo = showLogo();
        if (logo  == null) {
            logger.debug("No logo to show chosen");
            return;
        }

        ImageIcon imageIcon = null;
        if (logo.equals("none")) {
            return;
        }
        /*
         If you want to add images, remember to change code in these places:
         flys-artifacts:
         XYChartGenerator.java
         Timeseries*Generator.java and
         in the flys-client projects Chart*Propert*Editor.java.
         Also, these images have to be put in
         flys-artifacts/src/main/resources/images/
         flys-client/src/main/webapp/images/
         */
        java.net.URL imageURL;
        if (logo.equals("Intevation")) {
            imageURL = XYChartGenerator.class.getResource("/images/intevation.png");
        }
        else { // TODO else if ...
            imageURL = XYChartGenerator.class.getResource("/images/bfg_logo.gif");
        }
        imageIcon = new ImageIcon(imageURL);
        double xPos = 0d, yPos = 0d;

        String placeh = logoHPlace();
        String placev = logoVPlace();

        if (placev == null || placev.equals("none")) {
            placev = "top";
        }
        if (placev.equals("top")) {
            yPos = (Double)getYBounds(0).getUpper();
        }
        else if (placev.equals("bottom")) {
            yPos = (Double)getYBounds(0).getLower();
        }
        else if (placev.equals("center")) {
            yPos = ((Double)getYBounds(0).getUpper() + (Double)getYBounds(0).getLower())/2d;
        }
        else {
            logger.debug("Unknown place-v value: " + placev);
        }

        if (placeh == null || placeh.equals("none")) {
            placeh = "center";
        }
        if (placeh.equals("left")) {
            xPos = getLeftX();
        }
        else if (placeh.equals("right")) {
            xPos = getRightX();
        }
        else if (placeh.equals("center")) {
            xPos = ((Long)getXBounds(0).getUpper() + (Long)getXBounds(0).getLower())/2d;
        }
        else {
            logger.debug("Unknown place-h value: " + placeh);
        }

        logger.debug("logo position: " + xPos + "/" + yPos);

        org.jfree.ui.RectangleAnchor anchor
            = org.jfree.ui.RectangleAnchor.TOP;
        if (placev.equals("top")) {
            if (placeh.equals("left")) {
                anchor = org.jfree.ui.RectangleAnchor.TOP_LEFT;
            }
            else if (placeh.equals("right")) {
                anchor = org.jfree.ui.RectangleAnchor.TOP_RIGHT;
            }
            else if (placeh.equals("center")) {
                anchor = org.jfree.ui.RectangleAnchor.TOP;
            }
        }
        else if (placev.equals("bottom")) {
            if (placeh.equals("left")) {
                anchor = org.jfree.ui.RectangleAnchor.BOTTOM_LEFT;
            }
            else if (placeh.equals("right")) {
                anchor = org.jfree.ui.RectangleAnchor.BOTTOM_RIGHT;
            }
            else if (placeh.equals("center")) {
                anchor = org.jfree.ui.RectangleAnchor.BOTTOM;
            }
        }
        else if (placev.equals("center")) {
            if (placeh.equals("left")) {
                anchor = org.jfree.ui.RectangleAnchor.LEFT;
            }
            else if (placeh.equals("right")) {
                anchor = org.jfree.ui.RectangleAnchor.RIGHT;
            }
            else if (placeh.equals("center")) {
                anchor = org.jfree.ui.RectangleAnchor.CENTER;
            }
        }

        XYAnnotation xyannotation =
            new XYImageAnnotation(xPos, yPos, imageIcon.getImage(), anchor);
        plot.getRenderer().addAnnotation(xyannotation, org.jfree.ui.Layer.BACKGROUND);
    }


    @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};
    }


    /** Get (zoom)values from request. */
    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);
        }
    }


    /**
     * @param plot the plot.
     * @param axis the value (x, time) axis of which to set bounds.
     * @param total the current bounds (?).
     */
    protected void zoomX(
        XYPlot    plot,
        ValueAxis axis,
        Bounds    total,//we could equally nicely getXBounds(0)
        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 = Math.round(min + user.getLower().doubleValue() * diff);
            long newMax = 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);
            }
        }
    }


    /**
     * @param user zoom values in percent.
     */
    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.
     *
     * (Duplicate in XYChartGenerator).
     *
     * @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);
    }


    protected Date decodeXAxisValue(JSONArray array) throws JSONException, ParseException {
        try {
            double x = array.getDouble(0);
            long l = (new Double(x)).longValue();
            return new Date(l);
        }
        catch(JSONException ex) {
            String str = array.getString(0);
            DateFormat df = DateFormat.getDateInstance(
                    DateFormat.MEDIUM, Resources.getLocale(context.getMeta()));
            return df.parse(str);
        }
    }

    /**
     * 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 y    = array.getDouble(1);
                String name = array.getString(2);
                boolean act = array.getBoolean(3);
                if (!act) {
                    continue;
                }

                Date date = decodeXAxisValue(array);

                Day day = new Day(date);
                series.add(day, y, false);
                names.put(day, name);
            }
        }
        catch(JSONException ex) {
            logger.error("Could not decode json");
        }
        catch(ParseException ex) {
            logger.error("Could not parse date string");
        }

        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));
            logger.debug("doPoints(): x=" + x + " y=" + y);
        }
        RiverAnnotation annotations =
            new RiverAnnotation(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);
    }

    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);
    }

    private LegendItem getLegendItemFor(XYPlot plot, String interSeriesKey) {
        LegendItemCollection litems = plot.getLegendItems();
        Iterator<LegendItem> iter = litems.iterator();
        while(iter.hasNext()) {
            LegendItem item = iter.next();
            if(interSeriesKey.startsWith(item.getSeriesKey().toString())) {
                return item;
            }
        }
        return null;
    }

    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)) {
                    // Interpolated points are drawn unfilled
                    if (attributes.get(key).equals("interpolate")) {
                        XYLineAndShapeRenderer renderer =
                                series.getStyle().getRenderer();
                        renderer.setSeriesPaint(
                            j,
                            renderer.getSeriesFillPaint(j));
                        renderer.setSeriesShapesFilled(j, false);

                        LegendItem legendItem = getLegendItemFor(plot, key);
                        if(legendItem != null) {
                            LegendItem interLegend = new LegendItem(
                                    legendItem.getLabel(),
                                    legendItem.getDescription(),
                                    legendItem.getToolTipText(),
                                    legendItem.getURLText(),
                                    legendItem.isShapeVisible(),
                                    legendItem.getShape(),
                                    false, // shapeFilled?
                                    legendItem.getFillPaint(),
                                    true,  // shapeOutlineVisible?
                                    renderer.getSeriesFillPaint(j),
                                    legendItem.getOutlineStroke(),
                                    legendItem.isLineVisible(),
                                    legendItem.getLine(),
                                    legendItem.getLineStroke(),
                                    legendItem.getLinePaint()
                                    );
                            interLegend.setSeriesKey(series.getKey());
                            logger.debug("applySeriesAttributes: draw unfilled legend item");
                            plot.getLegendItems().add(interLegend);
                        }
                    }
                }

                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