/* ===========================================================
* Orson Charts : a 3D chart library for the Java(tm) platform
* ===========================================================
*
* (C)opyright 2013-2016, by Object Refinery Limited. All rights reserved.
*
* http://www.object-refinery.com/orsoncharts/index.html
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* [Oracle and Java are registered trademarks of Oracle and/or its affiliates.
* Other names may be trademarks of their respective owners.]
*
* If you do not wish to be bound by the terms of the GPL, an alternative
* commercial license can be purchased. For details, please see visit the
* Orson Charts home page:
*
* http://www.object-refinery.com/orsoncharts/index.html
*
*/
package com.orsoncharts.axis;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.Shape;
import java.awt.geom.Line2D;
import java.awt.geom.Point2D;
import java.awt.font.LineMetrics;
import java.text.DecimalFormat;
import java.text.Format;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.List;
import com.orsoncharts.Chart3DHints;
import com.orsoncharts.Range;
import com.orsoncharts.graphics3d.RenderedElement;
import com.orsoncharts.graphics3d.RenderingInfo;
import com.orsoncharts.graphics3d.Utils2D;
import com.orsoncharts.interaction.InteractiveElementType;
import com.orsoncharts.plot.CategoryPlot3D;
import com.orsoncharts.plot.XYZPlot;
import com.orsoncharts.util.TextUtils;
import com.orsoncharts.util.TextAnchor;
import com.orsoncharts.util.ArgChecks;
import com.orsoncharts.util.ObjectUtils;
/**
* A numerical axis for use with 3D plots (implements {@link ValueAxis3D}).
* In a {@link CategoryPlot3D} the value axis (the vertical one) is numerical,
* and in an {@link XYZPlot} all the axes (x, y and z) are numerical - for
* all these cases an instance of this class can be used.
* <br><br>
* NOTE: This class is serializable, but the serialization format is subject
* to change in future releases and should not be relied upon for persisting
* instances of this class.
*/
@SuppressWarnings("serial")
public class NumberAxis3D extends AbstractValueAxis3D implements ValueAxis3D,
Serializable {
/**
* A flag indicating whether or not the auto-range calculation should
* include zero.
*/
private boolean autoRangeIncludesZero;
/**
* A flag that controls how zero is handled when it falls within the
* margins. If {@code true}, the margin is truncated at zero, if
* {@code false} the margin is not changed.
*/
private boolean autoRangeStickyZero;
/**
* The tick selector (if not {@code null}, then auto-tick selection is
* used).
*/
private TickSelector tickSelector;
/**
* The tick size. If the tickSelector is not {@code null} then it is
* used to auto-select an appropriate tick size and format.
*/
private double tickSize;
/** The tick formatter (never {@code null}). */
private Format tickLabelFormatter;
/**
* Creates a new axis with the specified label and default attributes.
*
* @param label the axis label ({@code null} permitted).
*/
public NumberAxis3D(String label) {
this(label, new Range(0.0, 1.0));
}
/**
* Creates a new axis with the specified label and range.
*
* @param label the axis label ({@code null} permitted).
* @param range the range ({@code null} not permitted).
*/
public NumberAxis3D(String label, Range range) {
super(label, range);
this.autoRangeIncludesZero = false;
this.autoRangeStickyZero = true;
this.tickSelector = new NumberTickSelector();
this.tickSize = range.getLength() / 10.0;
this.tickLabelFormatter = new DecimalFormat("0.00");
}
/**
* Returns the flag that determines whether or not the auto range
* mechanism should force zero to be included in the range. The default
* value is {@code false}.
*
* @return A boolean.
*/
public boolean getAutoRangeIncludesZero() {
return this.autoRangeIncludesZero;
}
/**
* Sets the flag that controls whether or not the auto range mechanism
* should force zero to be included in the axis range, and sends an
* {@link Axis3DChangeEvent} to all registered listeners.
*
* @param include the new flag value.
*/
public void setAutoRangeIncludeZero(boolean include) {
this.autoRangeIncludesZero = include;
fireChangeEvent(true);
}
/**
* Returns the flag that controls the behaviour of the auto range
* mechanism when zero falls into the axis margins. The default value
* is {@code true}.
*
* @return A boolean.
*
* @see #setAutoRangeStickyZero(boolean)
*/
public boolean getAutoRangeStickyZero() {
return this.autoRangeStickyZero;
}
/**
* Sets the flag that controls the behaviour of the auto range mechanism
* when zero falls into the axis margins. If {@code true}, when
* zero is in the axis margin the axis range is truncated at zero. If
* {@code false}, there is no special treatment.
*
* @param sticky the new flag value.
*/
public void setAutoRangeStickyZero(boolean sticky) {
this.autoRangeStickyZero = sticky;
fireChangeEvent(true);
}
/**
* Returns the tick selector, an object that is responsible for choosing
* standard tick units for the axis. The default value is a default
* instance of {@link NumberTickSelector}.
*
* @return The tick selector.
*
* @see #setTickSelector(TickSelector)
*/
public TickSelector getTickSelector() {
return this.tickSelector;
}
/**
* Sets the tick selector and sends an {@link Axis3DChangeEvent} to all
* registered listeners.
*
* @param selector the selector ({@code null} permitted).
*
* @see #getTickSelector()
*/
public void setTickSelector(TickSelector selector) {
this.tickSelector = selector;
fireChangeEvent(false);
}
/**
* Returns the tick size (to be used when the tick selector is
* {@code null}).
*
* @return The tick size.
*/
public double getTickSize() {
return this.tickSize;
}
/**
* Sets the tick size and sends an {@link Axis3DChangeEvent} to all
* registered listeners.
*
* @param tickSize the new tick size.
*/
public void setTickSize(double tickSize) {
this.tickSize = tickSize;
fireChangeEvent(false);
}
/**
* Returns the tick label formatter. The default value is
* {@code DecimalFormat("0.00")}.
*
* @return The tick label formatter (never {@code null}).
*/
public Format getTickLabelFormatter() {
return this.tickLabelFormatter;
}
/**
* Sets the formatter for the tick labels and sends an
* {@link Axis3DChangeEvent} to all registered listeners.
*
* @param formatter the formatter ({@code null} not permitted).
*/
public void setTickLabelFormatter(Format formatter) {
ArgChecks.nullNotPermitted(formatter, "formatter");
this.tickLabelFormatter = formatter;
fireChangeEvent(false);
}
/**
* Adjusts the range by adding the lower and upper margins and taking into
* account also the {@code autoRangeStickyZero} flag.
*
* @param range the range ({@code null} not permitted).
*
* @return The adjusted range.
*/
@Override
protected Range adjustedDataRange(Range range) {
ArgChecks.nullNotPermitted(range, "range");
double lm = range.getLength() * getLowerMargin();
double um = range.getLength() * getUpperMargin();
double lowerBound = range.getMin() - lm;
double upperBound = range.getMax() + um;
// does zero fall in the margins?
if (this.autoRangeStickyZero) {
if (0.0 <= range.getMin() && 0.0 > lowerBound) {
lowerBound = 0.0;
}
if (0.0 >= range.getMax() && 0.0 < upperBound) {
upperBound = 0.0;
}
}
if ((upperBound - lowerBound) < getMinAutoRangeLength()) {
double adj = (getMinAutoRangeLength() - (upperBound - lowerBound))
/ 2.0;
lowerBound -= adj;
upperBound += adj;
}
return new Range(lowerBound, upperBound);
}
/**
* Draws the axis to the supplied graphics target ({@code g2}, with the
* specified starting and ending points for the line. This method is used
* internally, you should not need to call it directly.
*
* @param g2 the graphics target ({@code null} not permitted).
* @param pt0 the starting point ({@code null} not permitted).
* @param pt1 the ending point ({@code null} not permitted).
* @param opposingPt an opposing point (to determine which side of the
* axis line the labels should appear, {@code null} not permitted).
* @param tickData tick details ({@code null} not permitted).
* @param info an object to be populated with rendering info
* ({@code null} permitted).
* @param hinting perform element hinting?
*/
@Override
public void draw(Graphics2D g2, Point2D pt0, Point2D pt1,
Point2D opposingPt, List<TickData> tickData, RenderingInfo info,
boolean hinting) {
if (!isVisible()) {
return;
}
if (pt0.equals(pt1)) {
return;
}
// draw a line for the axis
g2.setStroke(getLineStroke());
g2.setPaint(getLineColor());
Line2D axisLine = new Line2D.Float(pt0, pt1);
g2.draw(axisLine);
// draw the tick marks and labels
g2.setFont(getTickLabelFont());
// we track the max width or height of the labels to know how far to
// offset the axis label when we draw it later
double maxTickLabelDim = 0.0;
if (getTickLabelOrientation().equals(LabelOrientation.PARALLEL)) {
LineMetrics lm = g2.getFontMetrics().getLineMetrics("123", g2);
maxTickLabelDim = lm.getHeight();
}
double tickMarkLength = getTickMarkLength();
double tickLabelOffset = getTickLabelOffset();
g2.setPaint(getTickMarkPaint());
g2.setStroke(getTickMarkStroke());
for (TickData t : tickData) {
if (tickMarkLength > 0.0) {
Line2D tickLine = Utils2D.createPerpendicularLine(axisLine,
t.getAnchorPt(), tickMarkLength, opposingPt);
g2.draw(tickLine);
}
String tickLabel = this.tickLabelFormatter.format(t.getDataValue());
if (getTickLabelOrientation().equals(
LabelOrientation.PERPENDICULAR)) {
maxTickLabelDim = Math.max(maxTickLabelDim,
g2.getFontMetrics().stringWidth(tickLabel));
}
}
if (getTickLabelsVisible()) {
g2.setPaint(getTickLabelColor());
if (getTickLabelOrientation().equals(
LabelOrientation.PERPENDICULAR)) {
drawPerpendicularTickLabels(g2, axisLine, opposingPt, tickData,
info, hinting);
} else {
drawParallelTickLabels(g2, axisLine, opposingPt, tickData,
info, hinting);
}
} else {
maxTickLabelDim = 0.0;
}
// draw the axis label (if any)...
if (getLabel() != null) {
Shape labelBounds = drawAxisLabel(getLabel(), g2, axisLine,
opposingPt, maxTickLabelDim + tickMarkLength
+ tickLabelOffset + getLabelOffset(), info, hinting);
}
}
/**
* Draws tick labels parallel to the axis.
*
* @param g2 the graphics target ({@code null} not permitted).
* @param axisLine the axis line ({@code null} not permitted).
* @param opposingPt an opposing point (to determine on which side the
* labels appear, {@code null} not permitted).
* @param tickData the tick data ({@code null} not permitted).
* @param info if not {@code null} this object will be updated with
* {@link RenderedElement} instances for each of the tick labels.
*/
private void drawParallelTickLabels(Graphics2D g2, Line2D axisLine,
Point2D opposingPt, List<TickData> tickData, RenderingInfo info,
boolean hinting) {
g2.setFont(getTickLabelFont());
double halfAscent = g2.getFontMetrics().getAscent() / 2.0;
for (TickData t : tickData) {
Line2D perpLine = Utils2D.createPerpendicularLine(axisLine,
t.getAnchorPt(), getTickMarkLength()
+ getTickLabelOffset() + halfAscent, opposingPt);
double axisTheta = Utils2D.calculateTheta(axisLine);
TextAnchor textAnchor = TextAnchor.CENTER;
if (axisTheta >= Math.PI / 2.0) {
axisTheta = axisTheta - Math.PI;
} else if (axisTheta <= -Math.PI / 2) {
axisTheta = axisTheta + Math.PI;
}
String tickLabel = this.tickLabelFormatter.format(
t.getDataValue());
if (hinting) {
Map<String, String> m = new HashMap<String, String>();
m.put("ref", "{\"type\": \"valueTickLabel\", \"axis\": \""
+ axisStr() + "\", \"value\": \""
+ t.getDataValue() + "\"}");
g2.setRenderingHint(Chart3DHints.KEY_BEGIN_ELEMENT, m);
}
Shape bounds = TextUtils.drawRotatedString(tickLabel, g2,
(float) perpLine.getX2(), (float) perpLine.getY2(),
textAnchor, axisTheta, textAnchor);
if (hinting) {
g2.setRenderingHint(Chart3DHints.KEY_END_ELEMENT, true);
}
if (info != null) {
RenderedElement tickLabelElement = new RenderedElement(
InteractiveElementType.VALUE_AXIS_TICK_LABEL, bounds);
tickLabelElement.setProperty("axis", axisStr());
tickLabelElement.setProperty("value",
Double.valueOf(t.getDataValue()));
info.addOffsetElement(tickLabelElement);
}
}
}
/**
* Draws tick labels perpendicular to the axis.
*
* @param g2 the graphics target ({@code null} not permitted).
* @param axisLine the axis line ({@code null} not permitted).
* @param opposingPt an opposing point (to determine on which side the
* labels appear, {@code null} not permitted).
* @param tickData the tick data ({@code null} not permitted).
* @param info if not {@code null} this object will be updated with
* {@link RenderedElement} instances for each of the tick labels.
*/
private void drawPerpendicularTickLabels(Graphics2D g2, Line2D axisLine,
Point2D opposingPt, List<TickData> tickData, RenderingInfo info,
boolean hinting) {
for (TickData t : tickData) {
double theta = Utils2D.calculateTheta(axisLine);
double thetaAdj = theta + Math.PI / 2.0;
if (thetaAdj < -Math.PI / 2.0) {
thetaAdj = thetaAdj + Math.PI;
}
if (thetaAdj > Math.PI / 2.0) {
thetaAdj = thetaAdj - Math.PI;
}
Line2D perpLine = Utils2D.createPerpendicularLine(axisLine,
t.getAnchorPt(), getTickMarkLength() + getTickLabelOffset(),
opposingPt);
double perpTheta = Utils2D.calculateTheta(perpLine);
TextAnchor textAnchor = TextAnchor.CENTER_LEFT;
if (Math.abs(perpTheta) > Math.PI / 2.0) {
textAnchor = TextAnchor.CENTER_RIGHT;
}
String tickLabel = this.tickLabelFormatter.format(
t.getDataValue());
if (hinting) {
Map<String, String> m = new HashMap<String, String>();
m.put("ref", "{\"type\": \"valueTickLabel\", \"axis\": \""
+ axisStr() + "\", \"value\": \""
+ t.getDataValue() + "\"}");
g2.setRenderingHint(Chart3DHints.KEY_BEGIN_ELEMENT, m);
}
Shape bounds = TextUtils.drawRotatedString(tickLabel, g2,
(float) perpLine.getX2(), (float) perpLine.getY2(),
textAnchor, thetaAdj, textAnchor);
if (hinting) {
g2.setRenderingHint(Chart3DHints.KEY_END_ELEMENT, true);
}
if (info != null) {
RenderedElement tickLabelElement = new RenderedElement(
InteractiveElementType.VALUE_AXIS_TICK_LABEL, bounds);
tickLabelElement.setProperty("axis", axisStr());
tickLabelElement.setProperty("value",
Double.valueOf(t.getDataValue()));
info.addOffsetElement(tickLabelElement);
}
}
}
/**
* Converts a data value to world coordinates, taking into account the
* current axis range (assumes the world axis is zero-based and has the
* specified length).
*
* @param value the data value (in axis units).
* @param length the length of the (zero based) world axis.
*
* @return A world coordinate.
*/
@Override
public double translateToWorld(double value, double length) {
double p = getRange().percent(value, isInverted());
return length * p;
}
/**
* Selects a tick size that is appropriate for drawing the axis from
* {@code pt0} to {@code pt1}.
*
* @param g2 the graphics target ({@code null} not permitted).
* @param pt0 the starting point for the axis.
* @param pt1 the ending point for the axis.
* @param opposingPt a point on the opposite side of the line from where
* the labels should be drawn.
*/
@Override
public double selectTick(Graphics2D g2, Point2D pt0, Point2D pt1,
Point2D opposingPt) {
if (this.tickSelector == null) {
return this.tickSize;
}
g2.setFont(getTickLabelFont());
FontMetrics fm = g2.getFontMetrics(getTickLabelFont());
double length = pt0.distance(pt1);
LabelOrientation orientation = getTickLabelOrientation();
if (orientation.equals(LabelOrientation.PERPENDICULAR)) {
// based on the font height, we can determine roughly how many tick
// labels will fit in the length available
double height = fm.getHeight();
// the tickLabelFactor allows some control over how dense the labels
// will be
int maxTicks = (int) (length / (height * getTickLabelFactor()));
if (maxTicks > 2 && this.tickSelector != null) {
double rangeLength = getRange().getLength();
this.tickSelector.select(rangeLength / 2.0);
// step through until we have too many ticks OR we run out of
// tick sizes
int tickCount = (int) (rangeLength
/ this.tickSelector.getCurrentTickSize());
while (tickCount < maxTicks) {
this.tickSelector.previous();
tickCount = (int) (rangeLength
/ this.tickSelector.getCurrentTickSize());
}
this.tickSelector.next();
this.tickSize = this.tickSelector.getCurrentTickSize();
this.tickLabelFormatter
= this.tickSelector.getCurrentTickLabelFormat();
} else {
this.tickSize = Double.NaN;
}
} else if (orientation.equals(LabelOrientation.PARALLEL)) {
// choose a unit that is at least as large as the length of the axis
this.tickSelector.select(getRange().getLength());
boolean done = false;
while (!done) {
if (this.tickSelector.previous()) {
// estimate the label widths, and do they overlap?
Format f = this.tickSelector.getCurrentTickLabelFormat();
String s0 = f.format(this.range.getMin());
String s1 = f.format(this.range.getMax());
double w0 = fm.stringWidth(s0);
double w1 = fm.stringWidth(s1);
double w = Math.max(w0, w1);
int n = (int) (length / (w * this.getTickLabelFactor()));
if (n < getRange().getLength()
/ tickSelector.getCurrentTickSize()) {
tickSelector.next();
done = true;
}
} else {
done = true;
}
}
this.tickSize = this.tickSelector.getCurrentTickSize();
this.tickLabelFormatter
= this.tickSelector.getCurrentTickLabelFormat();
}
return this.tickSize;
}
/**
* Generates a list of tick data items for the specified tick unit. This
* data will be passed to the 3D engine and will be updated with a 2D
* projection that can later be used to write the axis tick labels in the
* appropriate places.
* <br><br>
* If {@code tickUnit} is {@code Double.NaN}, then tick data is
* generated for just the bounds of the axis.
*
* @param tickUnit the tick unit.
*
* @return A list of tick data (never {@code null}).
*/
@Override
public List<TickData> generateTickData(double tickUnit) {
List<TickData> result = new ArrayList<TickData>();
if (Double.isNaN(tickUnit)) {
result.add(new TickData(0, getRange().getMin()));
result.add(new TickData(1, getRange().getMax()));
} else {
double x = tickUnit * Math.ceil(this.range.getMin() / tickUnit);
while (x <= this.range.getMax()) {
result.add(new TickData(this.range.percent(x, isInverted()),
x));
x += tickUnit;
}
}
return result;
}
/**
* Tests this instance for equality with an arbitrary object.
*
* @param obj the object to test against ({@code null} permitted).
*
* @return A boolean.
*/
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof NumberAxis3D)) {
return false;
}
NumberAxis3D that = (NumberAxis3D) obj;
if (this.autoRangeIncludesZero != that.autoRangeIncludesZero) {
return false;
}
if (this.autoRangeStickyZero != that.autoRangeStickyZero) {
return false;
}
if (this.tickSize != that.tickSize) {
return false;
}
if (!ObjectUtils.equals(this.tickSelector, that.tickSelector)) {
return false;
}
if (!this.tickLabelFormatter.equals(that.tickLabelFormatter)) {
return false;
}
return super.equals(obj);
}
/**
* Returns a hash code for this instance.
*
* @return A hash code.
*/
@Override
public int hashCode() {
int hash = 3;
hash = 59 * hash + (int) (Double.doubleToLongBits(this.tickSize)
^ (Double.doubleToLongBits(this.tickSize) >>> 32));
hash = 59 * hash + ObjectUtils.hashCode(this.tickLabelFormatter);
return hash;
}
}