view artifacts/src/main/java/org/dive4elements/river/exports/TimeseriesChartGenerator.java @ 6332:f5bb53106ae8

Remove createBarriersLayer and createBarriers The generated mapfiles did not work and were just confusing. This looks like historical cruft that was never deleted. The real barrier mapfiles are created in the Floodmap state
author Andre Heinecke <aheinecke@intevation.de>
date Thu, 13 Jun 2013 17:24:56 +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