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