001/* =========================================================== 002 * Orson Charts : a 3D chart library for the Java(tm) platform 003 * =========================================================== 004 * 005 * (C)opyright 2013-2016, by Object Refinery Limited. All rights reserved. 006 * 007 * http://www.object-refinery.com/orsoncharts/index.html 008 * 009 * This program is free software: you can redistribute it and/or modify 010 * it under the terms of the GNU General Public License as published by 011 * the Free Software Foundation, either version 3 of the License, or 012 * (at your option) any later version. 013 * 014 * This program is distributed in the hope that it will be useful, 015 * but WITHOUT ANY WARRANTY; without even the implied warranty of 016 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 017 * GNU General Public License for more details. 018 * 019 * You should have received a copy of the GNU General Public License 020 * along with this program. If not, see <http://www.gnu.org/licenses/>. 021 * 022 * [Oracle and Java are registered trademarks of Oracle and/or its affiliates. 023 * Other names may be trademarks of their respective owners.] 024 * 025 * If you do not wish to be bound by the terms of the GPL, an alternative 026 * commercial license can be purchased. For details, please see visit the 027 * Orson Charts home page: 028 * 029 * http://www.object-refinery.com/orsoncharts/index.html 030 * 031 */ 032 033package com.orsoncharts.axis; 034 035import java.awt.FontMetrics; 036import java.awt.Graphics2D; 037import java.awt.Shape; 038import java.awt.geom.Line2D; 039import java.awt.geom.Point2D; 040import java.awt.font.LineMetrics; 041import java.text.DecimalFormat; 042import java.text.Format; 043import java.io.Serializable; 044import java.util.ArrayList; 045import java.util.HashMap; 046import java.util.Map; 047import java.util.List; 048 049import com.orsoncharts.Chart3DHints; 050import com.orsoncharts.Range; 051import com.orsoncharts.graphics3d.RenderedElement; 052import com.orsoncharts.graphics3d.RenderingInfo; 053import com.orsoncharts.graphics3d.Utils2D; 054import com.orsoncharts.interaction.InteractiveElementType; 055import com.orsoncharts.plot.CategoryPlot3D; 056import com.orsoncharts.plot.XYZPlot; 057import com.orsoncharts.util.TextUtils; 058import com.orsoncharts.util.TextAnchor; 059import com.orsoncharts.util.ArgChecks; 060import com.orsoncharts.util.ObjectUtils; 061 062/** 063 * A numerical axis for use with 3D plots (implements {@link ValueAxis3D}). 064 * In a {@link CategoryPlot3D} the value axis (the vertical one) is numerical, 065 * and in an {@link XYZPlot} all the axes (x, y and z) are numerical - for 066 * all these cases an instance of this class can be used. 067 * <br><br> 068 * NOTE: This class is serializable, but the serialization format is subject 069 * to change in future releases and should not be relied upon for persisting 070 * instances of this class. 071 */ 072@SuppressWarnings("serial") 073public class NumberAxis3D extends AbstractValueAxis3D implements ValueAxis3D, 074 Serializable { 075 076 /** 077 * A flag indicating whether or not the auto-range calculation should 078 * include zero. 079 */ 080 private boolean autoRangeIncludesZero; 081 082 /** 083 * A flag that controls how zero is handled when it falls within the 084 * margins. If {@code true}, the margin is truncated at zero, if 085 * {@code false} the margin is not changed. 086 */ 087 private boolean autoRangeStickyZero; 088 089 /** 090 * The tick selector (if not {@code null}, then auto-tick selection is 091 * used). 092 */ 093 private TickSelector tickSelector; 094 095 /** 096 * The tick size. If the tickSelector is not {@code null} then it is 097 * used to auto-select an appropriate tick size and format. 098 */ 099 private double tickSize; 100 101 /** The tick formatter (never {@code null}). */ 102 private Format tickLabelFormatter; 103 104 /** 105 * Creates a new axis with the specified label and default attributes. 106 * 107 * @param label the axis label ({@code null} permitted). 108 */ 109 public NumberAxis3D(String label) { 110 this(label, new Range(0.0, 1.0)); 111 } 112 113 /** 114 * Creates a new axis with the specified label and range. 115 * 116 * @param label the axis label ({@code null} permitted). 117 * @param range the range ({@code null} not permitted). 118 */ 119 public NumberAxis3D(String label, Range range) { 120 super(label, range); 121 this.autoRangeIncludesZero = false; 122 this.autoRangeStickyZero = true; 123 this.tickSelector = new NumberTickSelector(); 124 this.tickSize = range.getLength() / 10.0; 125 this.tickLabelFormatter = new DecimalFormat("0.00"); 126 } 127 128 /** 129 * Returns the flag that determines whether or not the auto range 130 * mechanism should force zero to be included in the range. The default 131 * value is {@code false}. 132 * 133 * @return A boolean. 134 */ 135 public boolean getAutoRangeIncludesZero() { 136 return this.autoRangeIncludesZero; 137 } 138 139 /** 140 * Sets the flag that controls whether or not the auto range mechanism 141 * should force zero to be included in the axis range, and sends an 142 * {@link Axis3DChangeEvent} to all registered listeners. 143 * 144 * @param include the new flag value. 145 */ 146 public void setAutoRangeIncludeZero(boolean include) { 147 this.autoRangeIncludesZero = include; 148 fireChangeEvent(true); 149 } 150 151 /** 152 * Returns the flag that controls the behaviour of the auto range 153 * mechanism when zero falls into the axis margins. The default value 154 * is {@code true}. 155 * 156 * @return A boolean. 157 * 158 * @see #setAutoRangeStickyZero(boolean) 159 */ 160 public boolean getAutoRangeStickyZero() { 161 return this.autoRangeStickyZero; 162 } 163 164 /** 165 * Sets the flag that controls the behaviour of the auto range mechanism 166 * when zero falls into the axis margins. If {@code true}, when 167 * zero is in the axis margin the axis range is truncated at zero. If 168 * {@code false}, there is no special treatment. 169 * 170 * @param sticky the new flag value. 171 */ 172 public void setAutoRangeStickyZero(boolean sticky) { 173 this.autoRangeStickyZero = sticky; 174 fireChangeEvent(true); 175 } 176 177 /** 178 * Returns the tick selector, an object that is responsible for choosing 179 * standard tick units for the axis. The default value is a default 180 * instance of {@link NumberTickSelector}. 181 * 182 * @return The tick selector. 183 * 184 * @see #setTickSelector(TickSelector) 185 */ 186 public TickSelector getTickSelector() { 187 return this.tickSelector; 188 } 189 190 /** 191 * Sets the tick selector and sends an {@link Axis3DChangeEvent} to all 192 * registered listeners. 193 * 194 * @param selector the selector ({@code null} permitted). 195 * 196 * @see #getTickSelector() 197 */ 198 public void setTickSelector(TickSelector selector) { 199 this.tickSelector = selector; 200 fireChangeEvent(false); 201 } 202 203 /** 204 * Returns the tick size (to be used when the tick selector is 205 * {@code null}). 206 * 207 * @return The tick size. 208 */ 209 public double getTickSize() { 210 return this.tickSize; 211 } 212 213 /** 214 * Sets the tick size and sends an {@link Axis3DChangeEvent} to all 215 * registered listeners. 216 * 217 * @param tickSize the new tick size. 218 */ 219 public void setTickSize(double tickSize) { 220 this.tickSize = tickSize; 221 fireChangeEvent(false); 222 } 223 224 /** 225 * Returns the tick label formatter. The default value is 226 * {@code DecimalFormat("0.00")}. 227 * 228 * @return The tick label formatter (never {@code null}). 229 */ 230 public Format getTickLabelFormatter() { 231 return this.tickLabelFormatter; 232 } 233 234 /** 235 * Sets the formatter for the tick labels and sends an 236 * {@link Axis3DChangeEvent} to all registered listeners. 237 * 238 * @param formatter the formatter ({@code null} not permitted). 239 */ 240 public void setTickLabelFormatter(Format formatter) { 241 ArgChecks.nullNotPermitted(formatter, "formatter"); 242 this.tickLabelFormatter = formatter; 243 fireChangeEvent(false); 244 } 245 246 /** 247 * Adjusts the range by adding the lower and upper margins and taking into 248 * account also the {@code autoRangeStickyZero} flag. 249 * 250 * @param range the range ({@code null} not permitted). 251 * 252 * @return The adjusted range. 253 */ 254 @Override 255 protected Range adjustedDataRange(Range range) { 256 ArgChecks.nullNotPermitted(range, "range"); 257 double lm = range.getLength() * getLowerMargin(); 258 double um = range.getLength() * getUpperMargin(); 259 double lowerBound = range.getMin() - lm; 260 double upperBound = range.getMax() + um; 261 // does zero fall in the margins? 262 if (this.autoRangeStickyZero) { 263 if (0.0 <= range.getMin() && 0.0 > lowerBound) { 264 lowerBound = 0.0; 265 } 266 if (0.0 >= range.getMax() && 0.0 < upperBound) { 267 upperBound = 0.0; 268 } 269 } 270 if ((upperBound - lowerBound) < getMinAutoRangeLength()) { 271 double adj = (getMinAutoRangeLength() - (upperBound - lowerBound)) 272 / 2.0; 273 lowerBound -= adj; 274 upperBound += adj; 275 } 276 return new Range(lowerBound, upperBound); 277 } 278 279 /** 280 * Draws the axis to the supplied graphics target ({@code g2}, with the 281 * specified starting and ending points for the line. This method is used 282 * internally, you should not need to call it directly. 283 * 284 * @param g2 the graphics target ({@code null} not permitted). 285 * @param pt0 the starting point ({@code null} not permitted). 286 * @param pt1 the ending point ({@code null} not permitted). 287 * @param opposingPt an opposing point (to determine which side of the 288 * axis line the labels should appear, {@code null} not permitted). 289 * @param tickData tick details ({@code null} not permitted). 290 * @param info an object to be populated with rendering info 291 * ({@code null} permitted). 292 * @param hinting perform element hinting? 293 */ 294 @Override 295 public void draw(Graphics2D g2, Point2D pt0, Point2D pt1, 296 Point2D opposingPt, List<TickData> tickData, RenderingInfo info, 297 boolean hinting) { 298 299 if (!isVisible()) { 300 return; 301 } 302 if (pt0.equals(pt1)) { 303 return; 304 } 305 306 // draw a line for the axis 307 g2.setStroke(getLineStroke()); 308 g2.setPaint(getLineColor()); 309 Line2D axisLine = new Line2D.Float(pt0, pt1); 310 g2.draw(axisLine); 311 312 // draw the tick marks and labels 313 g2.setFont(getTickLabelFont()); 314 // we track the max width or height of the labels to know how far to 315 // offset the axis label when we draw it later 316 double maxTickLabelDim = 0.0; 317 if (getTickLabelOrientation().equals(LabelOrientation.PARALLEL)) { 318 LineMetrics lm = g2.getFontMetrics().getLineMetrics("123", g2); 319 maxTickLabelDim = lm.getHeight(); 320 } 321 double tickMarkLength = getTickMarkLength(); 322 double tickLabelOffset = getTickLabelOffset(); 323 g2.setPaint(getTickMarkPaint()); 324 g2.setStroke(getTickMarkStroke()); 325 for (TickData t : tickData) { 326 if (tickMarkLength > 0.0) { 327 Line2D tickLine = Utils2D.createPerpendicularLine(axisLine, 328 t.getAnchorPt(), tickMarkLength, opposingPt); 329 g2.draw(tickLine); 330 } 331 String tickLabel = this.tickLabelFormatter.format(t.getDataValue()); 332 if (getTickLabelOrientation().equals( 333 LabelOrientation.PERPENDICULAR)) { 334 maxTickLabelDim = Math.max(maxTickLabelDim, 335 g2.getFontMetrics().stringWidth(tickLabel)); 336 } 337 } 338 339 if (getTickLabelsVisible()) { 340 g2.setPaint(getTickLabelColor()); 341 if (getTickLabelOrientation().equals( 342 LabelOrientation.PERPENDICULAR)) { 343 drawPerpendicularTickLabels(g2, axisLine, opposingPt, tickData, 344 info, hinting); 345 } else { 346 drawParallelTickLabels(g2, axisLine, opposingPt, tickData, 347 info, hinting); 348 } 349 } else { 350 maxTickLabelDim = 0.0; 351 } 352 353 // draw the axis label (if any)... 354 if (getLabel() != null) { 355 Shape labelBounds = drawAxisLabel(getLabel(), g2, axisLine, 356 opposingPt, maxTickLabelDim + tickMarkLength 357 + tickLabelOffset + getLabelOffset(), info, hinting); 358 } 359 } 360 361 /** 362 * Draws tick labels parallel to the axis. 363 * 364 * @param g2 the graphics target ({@code null} not permitted). 365 * @param axisLine the axis line ({@code null} not permitted). 366 * @param opposingPt an opposing point (to determine on which side the 367 * labels appear, {@code null} not permitted). 368 * @param tickData the tick data ({@code null} not permitted). 369 * @param info if not {@code null} this object will be updated with 370 * {@link RenderedElement} instances for each of the tick labels. 371 */ 372 private void drawParallelTickLabels(Graphics2D g2, Line2D axisLine, 373 Point2D opposingPt, List<TickData> tickData, RenderingInfo info, 374 boolean hinting) { 375 376 g2.setFont(getTickLabelFont()); 377 double halfAscent = g2.getFontMetrics().getAscent() / 2.0; 378 for (TickData t : tickData) { 379 Line2D perpLine = Utils2D.createPerpendicularLine(axisLine, 380 t.getAnchorPt(), getTickMarkLength() 381 + getTickLabelOffset() + halfAscent, opposingPt); 382 double axisTheta = Utils2D.calculateTheta(axisLine); 383 TextAnchor textAnchor = TextAnchor.CENTER; 384 if (axisTheta >= Math.PI / 2.0) { 385 axisTheta = axisTheta - Math.PI; 386 } else if (axisTheta <= -Math.PI / 2) { 387 axisTheta = axisTheta + Math.PI; 388 } 389 String tickLabel = this.tickLabelFormatter.format( 390 t.getDataValue()); 391 if (hinting) { 392 Map<String, String> m = new HashMap<String, String>(); 393 m.put("ref", "{\"type\": \"valueTickLabel\", \"axis\": \"" 394 + axisStr() + "\", \"value\": \"" 395 + t.getDataValue() + "\"}"); 396 g2.setRenderingHint(Chart3DHints.KEY_BEGIN_ELEMENT, m); 397 } 398 Shape bounds = TextUtils.drawRotatedString(tickLabel, g2, 399 (float) perpLine.getX2(), (float) perpLine.getY2(), 400 textAnchor, axisTheta, textAnchor); 401 if (hinting) { 402 g2.setRenderingHint(Chart3DHints.KEY_END_ELEMENT, true); 403 } 404 if (info != null) { 405 RenderedElement tickLabelElement = new RenderedElement( 406 InteractiveElementType.VALUE_AXIS_TICK_LABEL, bounds); 407 tickLabelElement.setProperty("axis", axisStr()); 408 tickLabelElement.setProperty("value", 409 Double.valueOf(t.getDataValue())); 410 info.addOffsetElement(tickLabelElement); 411 } 412 } 413 } 414 415 /** 416 * Draws tick labels perpendicular to the axis. 417 * 418 * @param g2 the graphics target ({@code null} not permitted). 419 * @param axisLine the axis line ({@code null} not permitted). 420 * @param opposingPt an opposing point (to determine on which side the 421 * labels appear, {@code null} not permitted). 422 * @param tickData the tick data ({@code null} not permitted). 423 * @param info if not {@code null} this object will be updated with 424 * {@link RenderedElement} instances for each of the tick labels. 425 */ 426 private void drawPerpendicularTickLabels(Graphics2D g2, Line2D axisLine, 427 Point2D opposingPt, List<TickData> tickData, RenderingInfo info, 428 boolean hinting) { 429 for (TickData t : tickData) { 430 double theta = Utils2D.calculateTheta(axisLine); 431 double thetaAdj = theta + Math.PI / 2.0; 432 if (thetaAdj < -Math.PI / 2.0) { 433 thetaAdj = thetaAdj + Math.PI; 434 } 435 if (thetaAdj > Math.PI / 2.0) { 436 thetaAdj = thetaAdj - Math.PI; 437 } 438 439 Line2D perpLine = Utils2D.createPerpendicularLine(axisLine, 440 t.getAnchorPt(), getTickMarkLength() + getTickLabelOffset(), 441 opposingPt); 442 double perpTheta = Utils2D.calculateTheta(perpLine); 443 TextAnchor textAnchor = TextAnchor.CENTER_LEFT; 444 if (Math.abs(perpTheta) > Math.PI / 2.0) { 445 textAnchor = TextAnchor.CENTER_RIGHT; 446 } 447 String tickLabel = this.tickLabelFormatter.format( 448 t.getDataValue()); 449 if (hinting) { 450 Map<String, String> m = new HashMap<String, String>(); 451 m.put("ref", "{\"type\": \"valueTickLabel\", \"axis\": \"" 452 + axisStr() + "\", \"value\": \"" 453 + t.getDataValue() + "\"}"); 454 g2.setRenderingHint(Chart3DHints.KEY_BEGIN_ELEMENT, m); 455 } 456 Shape bounds = TextUtils.drawRotatedString(tickLabel, g2, 457 (float) perpLine.getX2(), (float) perpLine.getY2(), 458 textAnchor, thetaAdj, textAnchor); 459 if (hinting) { 460 g2.setRenderingHint(Chart3DHints.KEY_END_ELEMENT, true); 461 } 462 if (info != null) { 463 RenderedElement tickLabelElement = new RenderedElement( 464 InteractiveElementType.VALUE_AXIS_TICK_LABEL, bounds); 465 tickLabelElement.setProperty("axis", axisStr()); 466 tickLabelElement.setProperty("value", 467 Double.valueOf(t.getDataValue())); 468 info.addOffsetElement(tickLabelElement); 469 } 470 } 471 } 472 473 /** 474 * Converts a data value to world coordinates, taking into account the 475 * current axis range (assumes the world axis is zero-based and has the 476 * specified length). 477 * 478 * @param value the data value (in axis units). 479 * @param length the length of the (zero based) world axis. 480 * 481 * @return A world coordinate. 482 */ 483 @Override 484 public double translateToWorld(double value, double length) { 485 double p = getRange().percent(value, isInverted()); 486 return length * p; 487 } 488 489 /** 490 * Selects a tick size that is appropriate for drawing the axis from 491 * {@code pt0} to {@code pt1}. 492 * 493 * @param g2 the graphics target ({@code null} not permitted). 494 * @param pt0 the starting point for the axis. 495 * @param pt1 the ending point for the axis. 496 * @param opposingPt a point on the opposite side of the line from where 497 * the labels should be drawn. 498 */ 499 @Override 500 public double selectTick(Graphics2D g2, Point2D pt0, Point2D pt1, 501 Point2D opposingPt) { 502 503 if (this.tickSelector == null) { 504 return this.tickSize; 505 } 506 g2.setFont(getTickLabelFont()); 507 FontMetrics fm = g2.getFontMetrics(getTickLabelFont()); 508 double length = pt0.distance(pt1); 509 LabelOrientation orientation = getTickLabelOrientation(); 510 if (orientation.equals(LabelOrientation.PERPENDICULAR)) { 511 // based on the font height, we can determine roughly how many tick 512 // labels will fit in the length available 513 double height = fm.getHeight(); 514 // the tickLabelFactor allows some control over how dense the labels 515 // will be 516 int maxTicks = (int) (length / (height * getTickLabelFactor())); 517 if (maxTicks > 2 && this.tickSelector != null) { 518 double rangeLength = getRange().getLength(); 519 this.tickSelector.select(rangeLength / 2.0); 520 // step through until we have too many ticks OR we run out of 521 // tick sizes 522 int tickCount = (int) (rangeLength 523 / this.tickSelector.getCurrentTickSize()); 524 while (tickCount < maxTicks) { 525 this.tickSelector.previous(); 526 tickCount = (int) (rangeLength 527 / this.tickSelector.getCurrentTickSize()); 528 } 529 this.tickSelector.next(); 530 this.tickSize = this.tickSelector.getCurrentTickSize(); 531 this.tickLabelFormatter 532 = this.tickSelector.getCurrentTickLabelFormat(); 533 } else { 534 this.tickSize = Double.NaN; 535 } 536 } else if (orientation.equals(LabelOrientation.PARALLEL)) { 537 // choose a unit that is at least as large as the length of the axis 538 this.tickSelector.select(getRange().getLength()); 539 boolean done = false; 540 while (!done) { 541 if (this.tickSelector.previous()) { 542 // estimate the label widths, and do they overlap? 543 Format f = this.tickSelector.getCurrentTickLabelFormat(); 544 String s0 = f.format(this.range.getMin()); 545 String s1 = f.format(this.range.getMax()); 546 double w0 = fm.stringWidth(s0); 547 double w1 = fm.stringWidth(s1); 548 double w = Math.max(w0, w1); 549 int n = (int) (length / (w * this.getTickLabelFactor())); 550 if (n < getRange().getLength() 551 / tickSelector.getCurrentTickSize()) { 552 tickSelector.next(); 553 done = true; 554 } 555 } else { 556 done = true; 557 } 558 } 559 this.tickSize = this.tickSelector.getCurrentTickSize(); 560 this.tickLabelFormatter 561 = this.tickSelector.getCurrentTickLabelFormat(); 562 } 563 return this.tickSize; 564 } 565 566 /** 567 * Generates a list of tick data items for the specified tick unit. This 568 * data will be passed to the 3D engine and will be updated with a 2D 569 * projection that can later be used to write the axis tick labels in the 570 * appropriate places. 571 * <br><br> 572 * If {@code tickUnit} is {@code Double.NaN}, then tick data is 573 * generated for just the bounds of the axis. 574 * 575 * @param tickUnit the tick unit. 576 * 577 * @return A list of tick data (never {@code null}). 578 */ 579 @Override 580 public List<TickData> generateTickData(double tickUnit) { 581 List<TickData> result = new ArrayList<TickData>(); 582 if (Double.isNaN(tickUnit)) { 583 result.add(new TickData(0, getRange().getMin())); 584 result.add(new TickData(1, getRange().getMax())); 585 } else { 586 double x = tickUnit * Math.ceil(this.range.getMin() / tickUnit); 587 while (x <= this.range.getMax()) { 588 result.add(new TickData(this.range.percent(x, isInverted()), 589 x)); 590 x += tickUnit; 591 } 592 } 593 return result; 594 } 595 596 /** 597 * Tests this instance for equality with an arbitrary object. 598 * 599 * @param obj the object to test against ({@code null} permitted). 600 * 601 * @return A boolean. 602 */ 603 @Override 604 public boolean equals(Object obj) { 605 if (obj == this) { 606 return true; 607 } 608 if (!(obj instanceof NumberAxis3D)) { 609 return false; 610 } 611 NumberAxis3D that = (NumberAxis3D) obj; 612 if (this.autoRangeIncludesZero != that.autoRangeIncludesZero) { 613 return false; 614 } 615 if (this.autoRangeStickyZero != that.autoRangeStickyZero) { 616 return false; 617 } 618 if (this.tickSize != that.tickSize) { 619 return false; 620 } 621 if (!ObjectUtils.equals(this.tickSelector, that.tickSelector)) { 622 return false; 623 } 624 if (!this.tickLabelFormatter.equals(that.tickLabelFormatter)) { 625 return false; 626 } 627 return super.equals(obj); 628 } 629 630 /** 631 * Returns a hash code for this instance. 632 * 633 * @return A hash code. 634 */ 635 @Override 636 public int hashCode() { 637 int hash = 3; 638 hash = 59 * hash + (int) (Double.doubleToLongBits(this.tickSize) 639 ^ (Double.doubleToLongBits(this.tickSize) >>> 32)); 640 hash = 59 * hash + ObjectUtils.hashCode(this.tickLabelFormatter); 641 return hash; 642 } 643 644}