/*
* Geotoolkit.org - An Open Source Java GIS Toolkit
* http://www.geotoolkit.org
*
* (C) 1998-2012, Open Source Geospatial Foundation (OSGeo)
* (C) 2009-2012, Geomatys
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotoolkit.gui.swing;
import java.awt.Font;
import java.awt.Shape;
import java.awt.Paint;
import java.awt.Color;
import java.awt.Stroke;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.Graphics2D;
import java.awt.BasicStroke;
import java.awt.RenderingHints;
import java.awt.geom.Path2D;
import java.awt.geom.Line2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentAdapter;
import java.util.Set;
import java.util.Map;
import java.util.List;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.IdentityHashMap;
import java.util.NoSuchElementException;
import java.io.Serializable;
import javax.vecmath.MismatchedSizeException;
import org.apache.sis.math.Vector;
import org.geotoolkit.resources.Errors;
import org.geotoolkit.display.axis.Axis2D;
import org.geotoolkit.display.axis.AbstractGraduation;
import org.geotoolkit.display.shape.TransformedShape;
import org.apache.sis.internal.util.UnmodifiableArrayList;
import org.apache.sis.util.Numbers;
import org.apache.sis.util.logging.Logging;
import static java.lang.Math.hypot;
/**
* Displays two axes and an arbitrary amount of series with zoom capability.
* Axes may have arbitrary orientation (they don't need to be perpendicular).
* It is possible for example to create a plot with a vertical <var>x</var>
* axis increasing downward, like the ones used in oceanography for plotting
* the data of <cite>Conductivity, Temperature, Depth</cite> (CTD) Sensors.
* Axes can also be oblique for simulating 3D effects.
* <p>
* Axes color and font can bet set with call to {@link #setForeground} and {@link #setFont}
* methods respectively. A scroll pane can be created with {@link #createScrollPane}.
* The example below creates a plot with zoom capability restricted to the <var>x</var> axis:
*
* {@preformat java
* float[] x = ...:
* float[] y = ...:
* Plot2D plot = new Plot2D(true, false);
* plot.addXAxis("Some x values");
* plot.addYAxis("Some y values");
* plot.addSeries("Random values", Color.BLUE, x, y);
* }
*
* <table cellspacing="24" cellpadding="12" align="center"><tr valign="top"><td>
* <img src="doc-files/Plot2D.png">
* </td><td width="500" bgcolor="lightblue">
* {@section Demo}
* The image on the left side gives an example of this widget appearance.
* To try this component in your browser, see the
* <a href="http://www.geotoolkit.org/demos/geotk-simples/applet/Plot2D.html">demonstration applet</a>.
* </td></tr></table>
*
* @author Martin Desruisseaux (MPO, Geomatys)
* @version 3.00
*
* @since 1.1
* @module
*/
@SuppressWarnings("serial")
public class Plot2D extends ZoomPane {
/**
* The axes for a given series. Instances of this class are used as values in the
* {@link Plot2D#series} map. The <var>x</var> and <var>y</var> axes in this {@code Entry}
* <strong>must</strong> be listed in {@link Plot2D#xAxes} and {@link Plot2D#yAxes} as well,
* but the list order don't have to be the same than the {@link Plot2D#series} order.
*/
private static final class Entry implements Serializable {
/** For cross-version compatibility. */
private static final long serialVersionUID = 1965783272889292496L;
/** The <var>x</var> and <var>y</var> axis for a given series. */
public final Axis2D xAxis, yAxis;
/** Constructs a new entry with the specified axis. */
public Entry(final Axis2D xAxis, final Axis2D yAxis) {
this.xAxis = xAxis;
this.yAxis = yAxis;
}
}
/**
* The set of <var>x</var> axes. There is usually only one axis, but more axes are allowed.
* All {@code Entry.xAxis} instance <strong>must</strong> appears in this list as well, but
* not necessarily in the same order.
*
* @see #addXAxis
* @see #addSeries
*/
private final List<Axis2D> xAxes = new ArrayList<>(3);
/**
* The set of <var>y</var> axes. There is usually only one axis, but more axes are allowed.
* All {@code Entry.yAxis} instance <strong>must</strong> appears in this list as well, but
* not necessarily in the same order.
*
* @see #addYAxis
* @see #addSeries
*/
private final List<Axis2D> yAxes = new ArrayList<>(3);
/**
* The set of series to plot. Keys are {@link Series} objects while values are {@code Entry}
* objects with the <var>x</var> and <var>y</var> axis to use for the series.
*
* @see #addSeries
*/
private final Map<Series,Entry> series = new LinkedHashMap<>();
/**
* Immutable version of {@code series} to be returned by {@link #getSeries}.
*
* @see #getSeries
*/
private final Set<Series> unmodifiableSeries = Collections.unmodifiableSet(series.keySet());
/**
* The axes to use for the next series to be added to this plot,
* or {@code null} if not yet created.
*/
private Entry currentAxes;
/**
* Title for next axis to be created, or {@code null} if the current axis should be used
* instead.
*
* @see #addXAxis
* @see #addYAxis
*/
private String nextXAxis="", nextYAxis="";
/**
* Bounding box of data in all series, or {@code null} if it must be recomputed.
*/
private transient Rectangle2D seriesBounds;
/**
* Margin between widget border and the drawing area.
*/
private int top=30, bottom=60, left=60, right=30;
/**
* Horizontal (x) and vertival (y) offset to apply to any supplementary axis.
*/
private int xOffset=20, yOffset=-20;
/**
* The widget's width and height when the graphics was rendered for the last time.
*/
private int lastWidth, lastHeight;
/**
* The plot title.
*/
private String title;
/**
* The title font.
*/
private Font titleFont = new Font("SansSerif", Font.BOLD, 16);
/**
* The default cycle of colors. They are used only if the user added a series
* without specifying explicitly the color to use for that series.
* <p>
* Those default colors may change in future Geotk versions. For safety, users are
* encouraged to specify the desired color explicitly when adding a series to a plot.
*/
protected static final List<Color> DEFAULT_COLORS = UnmodifiableArrayList.wrap(
new Color[] {Color.RED, Color.BLUE, Color.GREEN, Color.ORANGE, Color.CYAN, Color.MAGENTA}
);
/**
* The color to use for drawing grid lines, or {@code null} if the grid should not be drawn.
*/
private Color gridColor = Color.LIGHT_GRAY;
/**
* Listener class for various events.
*/
private static final class Listeners extends ComponentAdapter {
/**
* When resized, force the widget to layout its axis.
*/
@Override public void componentResized(final ComponentEvent event) {
final Plot2D c = (Plot2D) event.getSource();
c.layoutAxes(false);
}
}
/**
* Crestes an initially empty {@code Plot2D} with
* zoom capabilities on horizontal and vertical axis.
*/
public Plot2D() {
this(SCALE_X | SCALE_Y | TRANSLATE_X | TRANSLATE_Y | RESET);
}
/**
* Creates an initially empty {@code Plot2D} with
* zoom capabilities on the specified axis.
*
* @param zoomX {@code true} for allowing zooming on the <var>x</var> axis.
* @param zoomY {@code true} for allowing zooming on the <var>y</var> axis.
*/
public Plot2D(final boolean zoomX, final boolean zoomY) {
this((zoomX ? SCALE_X | TRANSLATE_X : 0) |
(zoomY ? SCALE_Y | TRANSLATE_Y : 0) | RESET);
}
/**
* Construct an initially empty {@code Plot2D} with the specified zoom capacities.
*
* @param zoomCapacities Allowed zoom types. It can be a
* bitwise combination of the following constants:
* {@link #SCALE_X SCALE_X}, {@link #SCALE_Y SCALE_Y},
* {@link #TRANSLATE_X TRANSLATE_X}, {@link #TRANSLATE_Y TRANSLATE_Y},
* {@link #ROTATE ROTATE}, {@link #RESET RESET} and {@link #DEFAULT_ZOOM DEFAULT_ZOOM}.
* @throws IllegalArgumentException If {@code zoomCapacities} is invalid.
*/
private Plot2D(final int zoomCapacities) {
super(zoomCapacities);
super.setPaintingWhileAdjusting(true);
final Listeners listeners = new Listeners();
super.addComponentListener(listeners);
}
/**
* Adds a new <var>x</var> axis to be used for the next series to be added to this plot.
* Special cases:
* <p>
* <ul>
* <li>If this method is never invoked, then a single <var>x</var> axis with no label
* will be used.</li>
* <li>If this method is invoked only once before the first series is added,
* then a single axis with the given label will be used.</li>
* <li>If this method is invoked more than once, then many <var>x</var> axes
* will be used. Additional axes will be drawn below the first axis.</li>
* </ul>
*
* @param label The axis label, or {@code null} if the axis should not have any label.
*/
public void addXAxis(String label) {
if (label == null) {
label = "";
}
nextXAxis = label.trim();
}
/**
* Adds a new <var>y</var> axis to be used for the next series to be added to this plot.
* Special cases:
* <p>
* <ul>
* <li>If this method is never invoked, then a single <var>y</var> axis with no label
* will be used.</li>
* <li>If this method is invoked only once before the first series is added,
* then a single axis with the given label will be used.</li>
* <li>If this method is invoked more than once, then many <var>y</var> axes
* will be used. Additional axes will be drawn at the left of the first axis.</li>
* </ul>
*
* @param label The axis label, or {@code null} if the axis should not have any label.
*/
public void addYAxis(String label) {
if (label == null) {
label = "";
}
nextYAxis = label.trim();
}
/**
* Adds a new serie to the plot. This convenience method wraps the given arrays into {@link Vector}
* objects and delegates to {@linkplain #addSeries(Map, Vector, Vector)}.
*
* @param name The series name, or {@code null} if none.
* @param color The color to use for plotting the series, or {@code null} for a default color.
* @param x The vector of <var>x</var> values.
* @param y The vector of <var>y</var> values.
* @return The series added.
* @throws MismatchedSizeException if the arrays don't have the same length.
*/
public Series addSeries(final String name, final Paint color, final float[] x, final float[] y)
throws MismatchedSizeException
{
return addSeries(properties(name, color), Vector.create(x, false), Vector.create(y, false));
}
/**
* Adds a new serie to the plot. This convenience method wraps the given arrays into {@link Vector}
* objects and delegates to {@link #addSeries(Map, Vector, Vector)}.
*
* @param name The series name, or {@code null} if none.
* @param color The color to use for plotting the series, or {@code null} for a default color.
* @param x The vector of <var>x</var> values.
* @param y The vector of <var>y</var> values.
* @return The series added.
* @throws MismatchedSizeException if the arrays don't have the same length.
*/
public Series addSeries(final String name, final Paint color, final double[] x, final double[] y)
throws MismatchedSizeException
{
return addSeries(properties(name, color), Vector.create(x, false), Vector.create(y, false));
}
/**
* Creates a properties map for the given arguments.
*/
private static Map<String,Object> properties(final String name, final Paint color) {
final Map<String,Object> properties = new HashMap<>(4);
properties.put("Name", name);
properties.put("Paint", color);
return properties;
}
/**
* Adds a new serie to the plot. This method creates a default {@link Series} implementation
* for the given vectors and delegates to {@link #addSeries(Series)}. The series is configured
* using the values given in the {@code properties} map. The following keys are recognized:
* <p>
* <ul>
* <li>{@code "Name"} for a {@link String} value to be used as the {@linkplain Series#name series name}.</li>
* <li>{@code "Paint"} for a {@link Paint} value to be used as the {@linkplain Series#paint series paint}.</li>
* </ul>
* <p>
* Any keys not recognized by this method are ignored and can be used by subclasses for
* their own additional information. Missing entries will be replaced by default values.
* Future versions of the {@code Plot2D} class may add more keys - this method is using
* a {@link Map} argument for allowing such extensibility.
*
* @param properties The properties to be given to the new series.
* @param x The vector of <var>x</var> values.
* @param y The vector of <var>y</var> values.
* @return The series added.
* @throws MismatchedSizeException if the arrays don't have the same length.
*/
public Series addSeries(Map<String,?> properties, final Vector x, final Vector y)
throws MismatchedSizeException
{
if (properties == null) {
properties = Collections.emptyMap();
}
String name = (String) properties.get("Name");
Paint color = (Paint) properties.get("Paint");
if (color == null) {
color = DEFAULT_COLORS.get(series.size() % DEFAULT_COLORS.size());
}
boolean fill = Boolean.TRUE.equals(properties.get("Fill")); // Undocumented (for now) feature.
return addSeries(new DefaultSeries(name, color, x, y, fill));
}
/**
* Adds a new serie to the plot. The new series will use the axes given by the last calls
* to {@link #addXAxis addXAxis} and {@link #addYAxis addYAxis}.
*
* @param series The serie to add.
* @return The added series, returned for convenience.
*/
public Series addSeries(final Series series) {
/*
* Computes the data extremums before to create axes because we need the zoom affine
* transform, and the calculation of the zoom transform needs the data extremums.
*/
final Rectangle2D bounds = series.bounds();
if (!bounds.isEmpty()) {
if (seriesBounds == null) {
seriesBounds = new Rectangle2D.Double();
seriesBounds.setRect(bounds);
} else {
seriesBounds.add(bounds);
}
if (zoomIsReset()) {
reset(); // Needed for computing the zoom.
}
}
/*
* Gets the axes, creating them if needed.
*/
final Axis2D xAxis;
final Axis2D yAxis;
boolean axisCreated = false;
try {
if (nextXAxis != null) {
axisCreated = true;
xAxis = new Axis2D();
layoutAxis(xAxis, xAxes.size(), true);
inferGraduation(xAxis, true); // Must be after layoutAxis.
final AbstractGraduation grad = (AbstractGraduation) xAxis.getGraduation();
grad.setTitle(nextXAxis);
xAxes.add(xAxis);
nextXAxis = null;
} else {
xAxis = currentAxes.xAxis;
}
if (nextYAxis != null) {
axisCreated = true;
yAxis = new Axis2D();
layoutAxis(yAxis, yAxes.size(), false);
inferGraduation(yAxis, false); // Must be after layoutAxis.
final AbstractGraduation grad = (AbstractGraduation) yAxis.getGraduation();
grad.setTitle(nextYAxis);
yAxes.add(yAxis);
nextYAxis = null;
} else {
yAxis = currentAxes.yAxis;
}
} catch (NoninvertibleTransformException exception) {
throw new IllegalStateException(exception);
}
if (axisCreated) {
// At least one axis has been created.
currentAxes = new Entry(xAxis, yAxis);
}
this.series.put(series, currentAxes);
if (title == null) {
title = series.name();
}
repaint();
return series;
}
/**
* Returns the set of series to be plotted.
* Series are painted in the order they are returned.
*
* @return The series to be plotted.
*/
public Set<Series> getSeries() {
return unmodifiableSeries;
}
/**
* Returns the color to use for drawing grid lines, or {@code null} if the grid should not
* be drawn.
*
* @return The current grid color, or {@code null} if none.
*/
public Color getGridColor() {
return gridColor;
}
/**
* Sets the color to use for drawing grid lines, or {@code null} if the grid should not
* be drawn.
*
* @param color The new grid color to use, or {@code null} if none.
*/
public void setGridColor(final Color color) {
gridColor = color;
}
/**
* Returns the {<var>x</var>, <var>y</var>} axes for the specified series.
*
* @param series The series for which axis are wanted.
* @return An array of length 2 containing <var>x</var> and <var>y</var> axis.
* @throws NoSuchElementException if this widget doesn't contains the specified series.
*/
public Axis2D[] getAxes(final Series series) throws NoSuchElementException {
final Entry entry = this.series.get(series);
if (entry != null) {
assert xAxes.indexOf(entry.xAxis) >= 0 : xAxes;
assert yAxes.indexOf(entry.yAxis) >= 0 : yAxes;
return new Axis2D[] {
entry.xAxis,
entry.yAxis
};
}
throw new NoSuchElementException(series.name());
}
/**
* Returns the minimal and maximal ordinate values of all (<var>x</var>,<var>y</var>) points
* to be plotted. This is the union of the bounding boxes of {@linkplain #getSeries all series}
* in this {@code Plot2D} component.
*
* @return The minimal and maximal ordinate values of (<var>x</var>,<var>y</var>) points.
*/
@Override
public Rectangle2D getArea() {
final Rectangle2D bounds = seriesBounds;
return (bounds != null) ? (Rectangle2D) bounds.clone() : null;
}
/**
* Returns the zoomable area in pixel coordinates. This area will not cover the
* full widget area, since some room will be left for painting axis and titles.
*/
@Override
protected Rectangle getZoomableBounds(Rectangle bounds) {
bounds = super.getZoomableBounds(bounds);
bounds.x += left;
bounds.y += top;
bounds.width -= (left + right);
bounds.height -= (top + bottom);
return bounds;
}
/**
* Returns the margin between the {@linkplain #getBounds() widget bounds} and the
* {@linkplain #getZoomableBounds zoomable bounds}. The zoomable bounds is the area
* where the graph will be plotted.
*
* @return The margin between widget bounds and the area where the graph is plotted.
*
* @since 3.00
*/
public Insets getMargin() {
return new Insets(top, left, bottom, right);
}
/**
* Sets the margin between the {@linkplain #getBounds() widget bounds} and the
* {@linkplain #getZoomableBounds zoomable bounds} to the given insets.
*
* @param margin The new margin between widget bounds and the area where the graph is plotted.
*
* @since 3.00
*/
public void setMargin(final Insets margin) {
top = margin.top;
left = margin.left;
bottom = margin.bottom;
right = margin.right;
}
/**
* Adds the given bounds to a map of bounds. If no bounds were assigned to the given axis,
* then the given bounds is copied and assigned to that axis. Otherwise - if a bounds
* already exists for the given axis - then that bounds is expanded in order to contains
* fully the given bounds.
*
* @param union The bounds computed up to date.
* @param axis The axis for which the bounds is to be updated.
* @param box The bounds to be added to the bounds associated to the given axis.
*/
private static void addAxisRange(final Map<Axis2D,Rectangle2D> unions,
final Axis2D axis, final Rectangle2D bounds)
{
Rectangle2D union = unions.get(axis);
if (union != null) {
union.add(bounds);
} else {
union = new Rectangle2D.Double();
union.setRect(bounds);
unions.put(axis, union);
}
}
/**
* Reinitializes the affine transform {@link #zoom zoom} in order to cancel any zoom, rotation or
* translation. The argument {@code yAxisUpward} indicates whether the <var>y</var> axis should
* point upwards, which is usually {@code true} for a plot.
*/
@Override
protected void reset(final Rectangle zoomableBounds, final boolean yAxisUpward) {
layoutAxes(true);
/*
* It is okay to use the same IdentityHashMap instance for both X and Y axes because the
* same Axis2D instance should never be used for both axes. Note however that a plain HashMap
* would not work because X and Y axis could be equal in the sense of Axis2D.equals(Object).
*/
final Map<Axis2D,Rectangle2D> unions = new IdentityHashMap<>();
for (final Map.Entry<Series,Entry> e : series.entrySet()) {
final Rectangle2D bounds = e.getKey().bounds();
final Entry entry = e.getValue();
addAxisRange(unions, entry.xAxis, bounds);
addAxisRange(unions, entry.yAxis, bounds);
}
for (final Axis2D axis : xAxes) {
final Rectangle2D bounds = unions.get(axis);
if (bounds != null) {
final AbstractGraduation grad = (AbstractGraduation) axis.getGraduation();
grad.setMinimum(bounds.getMinX());
grad.setMaximum(bounds.getMaxX());
}
}
for (final Axis2D axis : yAxes) {
final Rectangle2D bounds = unions.get(axis);
if (bounds != null) {
final AbstractGraduation grad = (AbstractGraduation) axis.getGraduation();
grad.setMinimum(bounds.getMinY());
grad.setMaximum(bounds.getMaxY());
}
}
super.reset(zoomableBounds, yAxisUpward);
}
/**
* Sets axes location. This method is automatically invoked when the axes need to be layout.
* This occurs for example when new axis are added, or when the component has been resized.
* This method does not change the graduations.
*
* @param force If {@code true}, then axes orientation and position are reset to their default
* value. If {@code false}, then this method tries to preserve axes orientation and
* position relative to widget's border.
*/
private void layoutAxes(final boolean force) {
final int width = getWidth();
final int height = getHeight();
final double tx = width - lastWidth;
final double ty = height - lastHeight;
int axisCount = 0;
for (final Axis2D axis : xAxes) {
if (force) {
layoutAxis(axis, axisCount, true);
} else {
resize(axis, tx, ty);
}
axisCount++;
}
axisCount = 0;
for (final Axis2D axis : yAxes) {
if (force) {
layoutAxis(axis, axisCount, false);
} else {
resize(axis, tx, ty);
}
axisCount++;
}
lastWidth = width;
lastHeight = height;
}
/**
* Forces the layout of the given axis. This method changes only the axis position,
* not the axis graduation. To change the graduation, invoke {@link #inferGraduation}
* <strong>after</strong> the axis has been put at its proper location on the widget area.
*
* @param axis The axis to layout.
* @param axisCount The index of the given axis.
* @param isX {@code true} if the given axis is an X axis, or {@code false} for an Y axis.
*/
private void layoutAxis(final Axis2D axis, final int axisCount, final boolean isX) {
final int width = super.getWidth();
final int height = super.getHeight();
final int x1, y1, x2, y2;
x1 = left;
y1 = height - bottom;
if (isX) {
x2 = width - right;
y2 = y1;
} else {
x2 = x1;
y2 = top;
}
axis.setLabelClockwise(isX);
axis.setLine(x1, y1, x2, y2);
translatePerpendicularly(axis, xOffset*axisCount, yOffset*axisCount);
}
/**
* Translates an axis in a perpendicular direction to its orientation.
* The following rules applies:
* <p>
* <ul>
* <li>If the axis is vertical, then the axis is translated horizontally
* by {@code tx} only. The {@code ty} argument is ignored.</li>
* <li>If the axis is horizontal, then the axis is translated vertically
* by {@code ty} only. The {@code tx} argument is ignored.</li>
* <li>If the axis is diagonal, then the axis is translated using the following
* formula (<var>theta</var> is the axis orientation relative to the horizontal):
*
* {@preformat math
* dx = tx*sin(theta)
* dy = ty*cos(theta)
* }
* </li>
* </ul>
*/
private static void translatePerpendicularly(final Axis2D axis, final double tx, final double ty) {
final double x1 = axis.getX1();
final double y1 = axis.getY1();
final double x2 = axis.getX2();
final double y2 = axis.getY2();
double dy = x2 - x1; // Note: dx and dy are really swapped - this is not an error.
double dx = y1 - y2;
double length = hypot(dx, dy);
dx *= tx/length;
dy *= ty/length;
axis.setLine(x1+dx, y1+dy, x2+dx, y2+dy);
}
/**
* Invoked when this component has been resized. This method adjust axis length while
* preserving their orientation and position relative to border.
*
* @param axis The axis to adjust.
* @param tx The change in component width.
* @param ty The change in component height.
*/
private static void resize(final Axis2D axis, final double tx, final double ty) {
final Point2D P1 = axis.getP1();
final Point2D P2 = axis.getP2();
final Point2D anchor, moveable;
if (distance(P1) <= distance(P2)) {
anchor = P1;
moveable = P2;
} else {
anchor = P2;
moveable = P1;
}
final double x = moveable.getX();
final double y = moveable.getY();
final double dx = x-anchor.getX();
final double dy = y-anchor.getY();
final double length = hypot(dx, dy);
moveable.setLocation(x + tx*dx/length,
y + ty*dy/length);
axis.setLine(P1, P2);
}
/**
* Returns the distance from the origin (0,0) to the given point. We compute the distance
* instead then the square of the distance because the later may overflow, while the Java
* {@link Math#hypot} implementation is designated for avoiding such overflow.
*/
private static double distance(final Point2D point) {
return hypot(point.getX(), point.getY());
}
/**
* Changes the {@linkplain #zoom zoom} by applying an affine transform. The {@code change}
* transform must express a change in the units of the {@linkplain #addSeries(Series) series
* added} to this widget. The location of axes will <strong>not</strong> change as a result
* of the given transform. Instead, the axis graduations will be updated with new minimal
* and maximal values matching the new zoom.
*/
@Override
public void transform(final AffineTransform change) {
super.transform(change);
try {
/*
* The affine transform from "data" to "pixel" coordinates changed. If we assume that
* the axes position don't change, then the graduations need to be updated in order
* to reflect the affine transform change. We perform this update by converting the
* axes coordinates from pixel units to data units. By definition, the (x,y) values
* of axes end-points in "data" units are the extremums on the X and Y axes respectively.
*/
for (final Axis2D axis : xAxes) {
inferGraduation(axis, true);
}
for (final Axis2D axis : yAxes) {
inferGraduation(axis, false);
}
} catch (NoninvertibleTransformException exception) {
Logging.unexpectedException(null, Plot2D.class, "transform", exception);
}
repaint();
}
/**
* Sets the graduation of the given axis according its current position. The following
* conditions must be hold before this method is invoked:
* <p>
* <ul>
* <li>The {@link #zoom} transform must be set to the "data to pixels" transform.</li>
* <li>The axis must be at its proper location in the widget area, typically through a
* call to {@link #layoutAxis} before this method call}.</li>
* </ul>
*
* @param axis The axis for which to set the graduation.
* @param isX {@code true} if the given axis is a X axis.
*/
private void inferGraduation(final Axis2D axis, final boolean isX) throws NoninvertibleTransformException {
Point2D P1 = axis.getP1();
Point2D P2 = axis.getP2();
P1 = zoom.inverseTransform(P1, P1);
P2 = zoom.inverseTransform(P2, P2);
double min, max;
if (isX) {
min = P1.getX();
max = P2.getX();
} else {
min = P1.getY();
max = P2.getY();
}
if (min > max) {
final double tmp = max;
max = min;
min = tmp;
}
final AbstractGraduation grad = (AbstractGraduation) axis.getGraduation();
grad.setMinimum(min);
grad.setMaximum(max);
}
/**
* Paints the axes and all series. At the opposite of typical {@link ZoomPane} subclasses, this
* method does not use directly the {@linkplain #zoom zoom} transform. The zoom is honored only
* indirectly since the axis graduations have been determined from the zoom by the
* {@link #transform(AffineTransform)} method.
*/
@Override
protected void paintComponent(final Graphics2D graphics) {
if (xAxes.isEmpty() || yAxes.isEmpty()) {
return;
}
final Rectangle bounds = getZoomableBounds(null);
final Stroke oldStroke = graphics.getStroke();
final Paint oldPaint = graphics.getPaint();
final Shape oldClip = graphics.getClip();
final Font oldFont = graphics.getFont();
final Object oldHint = graphics.getRenderingHint(RenderingHints.KEY_ANTIALIASING);
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
/*
* Draws the grid lines before to paint the series. We use the first (x,y)
* axes since they are the closest ones to the center of the graph area.
*/
final Axis2D xAxis = xAxes.get(0);
final Axis2D yAxis = yAxes.get(0);
final double xx1 = xAxis.getX1();
final double xy1 = xAxis.getY1();
final double xx2 = xAxis.getX2();
final double xy2 = xAxis.getY2();
final double yx1 = yAxis.getX1();
final double yy1 = yAxis.getY1();
final double yx2 = yAxis.getX2();
final double yy2 = yAxis.getY2();
final double xxD = (yx2 - yx1);
final double xyD = (yy2 - yy1);
final double yxD = (xx2 - xx1);
final double yyD = (xy2 - xy1);
final Line2D line = new Line2D.Double();
if (gridColor != null) {
graphics.setPaint(gridColor);
final Point2D.Double point = new Point2D.Double();
Axis2D.TickIterator tk = xAxis.new TickIterator(null);
for (tk.nextMajor(); !tk.isDone(); tk.nextMajor()) {
tk.currentPosition(point);
line.setLine(point.x, point.y, point.x + xxD, point.y + xyD);
graphics.draw(line);
}
tk = yAxis.new TickIterator(null);
for (tk.nextMajor(); !tk.isDone(); tk.nextMajor()) {
tk.currentPosition(point);
line.setLine(point.x, point.y, point.x + yxD, point.y + yyD);
graphics.draw(line);
}
}
/*
* Paints series first.
*/
graphics.clip(bounds);
graphics.setStroke(new BasicStroke((float) (1/getGraphicsScale())));
final TransformedShape transformed = new TransformedShape();
for (final Map.Entry<Series,Entry> e : series.entrySet()) {
final Series series = e.getKey();
final Entry entry = e.getValue();
final AffineTransform transform = Axis2D.createAffineTransform(entry.xAxis, entry.yAxis);
final Shape path = series.path();
transformed.setTransform(transform);
transformed.setOriginalShape(path);
graphics.setPaint(series.paint());
if (series instanceof DefaultSeries && ((DefaultSeries) series).fill) {
graphics.fill(transformed);
} else {
graphics.draw(transformed);
}
}
/*
* Paints axes on top of series, then paint the remainder of the box
* around the graph area.
*/
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldHint);
graphics.setStroke(oldStroke);
graphics.setPaint(getForeground());
graphics.setFont(getFont());
graphics.setClip(oldClip);
for (final Axis2D axis : xAxes) {
axis.paint(graphics);
}
for (final Axis2D axis : yAxes) {
axis.paint(graphics);
}
line.setLine(xx2, xy2, xx2+xxD, xy2+xyD); graphics.draw(line);
line.setLine(yx2, yy2, yx2+yxD, yy2+yyD); graphics.draw(line);
/*
* Paints the title.
*/
if (title != null) {
final FontRenderContext context = graphics.getFontRenderContext();
final GlyphVector glyphs = titleFont.createGlyphVector(context, title);
final Rectangle2D titleBounds = glyphs.getVisualBounds();
graphics.drawGlyphVector(glyphs, (float) ((getWidth() - titleBounds.getWidth()) / 2), 20);
}
graphics.setPaint(oldPaint);
graphics.setFont(oldFont);
}
/**
* Removes all axes and series from this plot.
*/
public void clear() {
series.clear();
xAxes .clear();
yAxes .clear();
nextXAxis = "";
nextYAxis = "";
seriesBounds = null;
currentAxes = null;
repaint();
}
/**
* A series to be displayed in a {@link Plot2D} widget. A {@code Series} contains the data
* to plot as a {@link Shape} object and the {@link Paint} to use for drawing the lines.
*
* @author Martin Desruisseaux (MPO, Geomatys)
* @version 3.00
*
* @since 1.1
* @module
*/
public interface Series {
/**
* Returns the name of this series. If only one series is plotted,
* then the name of that series will be used as the plot title.
*
* @return The name of this series, or {@code null} if none.
*/
String name();
/**
* Returns the color to use for plotting this series.
*
* @return The color to use for plotting this series.
*/
Paint paint();
/**
* Returns the bounding box of all <var>x</var> and <var>y</var> ordinates.
*
* @return The minimal and maximal (<var>x</var>, <var>y</var>) values.
*/
Rectangle2D bounds();
/**
* Returns the series data as a path.
*
* @return The (<var>x</var>,<var>y</var>) coordinates as a Java2D {@linkplain Shape shape}.
*/
Shape path();
}
/**
* Default implementation of {@link Plot2D.Series}.
*
* @author Martin Desruisseaux (MPO, Geomatys)
* @version 3.00
*
* @since 1.1
* @module
*/
private static final class DefaultSeries implements Series {
/**
* The series name.
*/
private final String name;
/**
* The color.
*/
private final Paint color;
/**
* The path, which may be float or double precision.
*/
private final Path2D path;
/**
* The minimal and maximum (<var>x</var>,<var>y</var>) values.
*/
private final Rectangle2D bounds;
/**
* {@code true} if the {@linkplain #path path} is a closed polygon which should be painted
* using {@link Graphics2D#fill(Shape)} instead of {@link Graphics2D#draw(Shape)}. This is
* not a public API at this time. The usual value is {@code false}.
*/
final boolean fill;
/**
* Constructs a series with the given name and (<var>x</var>,<var>y</var>) vectors.
*
* @throws MismatchedSizeException if the arrays don't have the same length.
*/
public DefaultSeries(final String name, final Paint color, final Vector x, final Vector y, boolean fill)
throws MismatchedSizeException
{
this.name = name;
this.color = color;
final int length = x.size();
if (length != y.size()) {
throw new MismatchedSizeException(Errors.format(Errors.Keys.MismatchedArrayLength_2, "x", "y"));
}
/*
* Creates a Path2D of Float type if it is sufficient
* for the provided data, or of Double tpe otherwise.
*/
final Class<?> type = Numbers.widestClass(
Numbers.primitiveToWrapper(x.getElementType()).asSubclass(Number.class),
Numbers.primitiveToWrapper(y.getElementType()).asSubclass(Number.class));
if (type == Double.class || type == Long.class) {
path = new Path2D.Double();
} else {
path = new Path2D.Float();
}
/*
* Creates the shape.
*/
boolean move = true;
for (int i=0; i<length; i++) {
double xi = x.doubleValue(i);
double yi = y.doubleValue(i);
if (Double.isNaN(yi) || Double.isNaN(xi)) {
if (!move) {
fill = false; // We will not be able to close the shape.
move = true;
}
continue;
}
if (move) {
move = false;
path.moveTo(xi, yi);
} else {
path.lineTo(xi, yi);
}
}
if (fill) {
path.closePath();
}
this.fill = fill;
bounds = path.getBounds2D();
}
/**
* Returns the series name.
*/
@Override
public String name() {
return name;
}
/**
* Returns the color for this series.
*/
@Override
public Paint paint() {
return color;
}
/**
* Returns the minimal and maximum (<var>x</var>,<var>y</var>) values.
*/
@Override
public Rectangle2D bounds() {
return (Rectangle2D) bounds.clone();
}
/**
* Returns the series data as a path. This method does not clone the path for
* performance reason. However since the {@link Shape} interface doesn't provide
* setter methods, it should be reasonable.
*/
@Override
public Shape path() {
return path;
}
/**
* Returns a string representation for debugging purpose.
*/
@Override
public String toString() {
final Rectangle2D bounds = this.bounds;
return "Series[\"" + name + "\", " +
"x=[" + bounds.getMinX() + " \u2026 " + bounds.getMaxX() + "], " +
"y=[" + bounds.getMinY() + " \u2026 " + bounds.getMaxY() + "]]";
}
}
}