1 /* 2 * Copyright (c) 2010, 2016, Oracle and/or its affiliates. All rights reserved. 3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. 4 * 5 * This code is free software; you can redistribute it and/or modify it 6 * under the terms of the GNU General Public License version 2 only, as 7 * published by the Free Software Foundation. Oracle designates this 8 * particular file as subject to the "Classpath" exception as provided 9 * by Oracle in the LICENSE file that accompanied this code. 10 * 11 * This code is distributed in the hope that it will be useful, but WITHOUT 12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 13 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 14 * version 2 for more details (a copy is included in the LICENSE file that 15 * accompanied this code). 16 * 17 * You should have received a copy of the GNU General Public License version 18 * 2 along with this work; if not, write to the Free Software Foundation, 19 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. 20 * 21 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA 22 * or visit www.oracle.com if you need additional information or have any 23 * questions. 24 */ 25 26 package javafx.scene.chart; 27 28 import java.util.ArrayList; 29 import java.util.List; 30 31 import javafx.animation.KeyFrame; 32 import javafx.animation.KeyValue; 33 import javafx.beans.property.BooleanProperty; 34 import javafx.beans.property.DoubleProperty; 35 import javafx.beans.property.ObjectProperty; 36 import javafx.beans.property.ObjectPropertyBase; 37 import javafx.beans.property.ReadOnlyDoubleProperty; 38 import javafx.beans.property.ReadOnlyDoubleWrapper; 39 import javafx.beans.property.SimpleDoubleProperty; 40 import javafx.beans.value.WritableValue; 41 import javafx.collections.FXCollections; 42 import javafx.collections.ListChangeListener; 43 import javafx.collections.ObservableList; 44 import javafx.geometry.Dimension2D; 45 import javafx.geometry.Side; 46 import javafx.util.Duration; 47 48 import com.sun.javafx.charts.ChartLayoutAnimator; 49 50 import javafx.css.StyleableBooleanProperty; 51 import javafx.css.StyleableDoubleProperty; 52 import javafx.css.CssMetaData; 53 54 import javafx.css.converter.BooleanConverter; 55 import javafx.css.converter.SizeConverter; 56 57 import java.util.Collections; 58 59 import javafx.css.Styleable; 60 import javafx.css.StyleableProperty; 61 62 /** 63 * A axis implementation that will works on string categories where each 64 * value as a unique category(tick mark) along the axis. 65 * @since JavaFX 2.0 66 */ 67 public final class CategoryAxis extends Axis<String> { 68 69 // -------------- PRIVATE FIELDS ------------------------------------------- 70 private List<String> allDataCategories = new ArrayList<String>(); 71 private boolean changeIsLocal = false; 72 /** This is the gap between one category and the next along this axis */ 73 private final DoubleProperty firstCategoryPos = new SimpleDoubleProperty(this, "firstCategoryPos", 0); 74 private Object currentAnimationID; 75 private final ChartLayoutAnimator animator = new ChartLayoutAnimator(this); 76 private ListChangeListener<String> itemsListener = c -> { 77 while (c.next()) { 78 if(!c.getAddedSubList().isEmpty()) { 79 // remove duplicates else they will get rendered on the chart. 80 // Ideally we should be using a Set for categories. 81 for (String addedStr : c.getAddedSubList()) 82 checkAndRemoveDuplicates(addedStr); 83 } 84 if (!isAutoRanging()) { 85 allDataCategories.clear(); 86 allDataCategories.addAll(getCategories()); 87 rangeValid = false; 88 } 89 requestAxisLayout(); 90 } 91 }; 92 93 // -------------- PUBLIC PROPERTIES ---------------------------------------- 94 95 /** The margin between the axis start and the first tick-mark */ 96 private DoubleProperty startMargin = new StyleableDoubleProperty(5) { 97 @Override protected void invalidated() { 98 requestAxisLayout(); 99 } 100 101 @Override public CssMetaData<CategoryAxis,Number> getCssMetaData() { 102 return StyleableProperties.START_MARGIN; 103 } 104 105 @Override 106 public Object getBean() { 107 return CategoryAxis.this; 108 } 109 110 @Override 111 public String getName() { 112 return "startMargin"; 113 } 114 }; 115 public final double getStartMargin() { return startMargin.getValue(); } 116 public final void setStartMargin(double value) { startMargin.setValue(value); } 117 public final DoubleProperty startMarginProperty() { return startMargin; } 118 119 /** The margin between the last tick mark and the axis end */ 120 private DoubleProperty endMargin = new StyleableDoubleProperty(5) { 121 @Override protected void invalidated() { 122 requestAxisLayout(); 123 } 124 125 126 @Override public CssMetaData<CategoryAxis,Number> getCssMetaData() { 127 return StyleableProperties.END_MARGIN; 128 } 129 130 @Override 131 public Object getBean() { 132 return CategoryAxis.this; 133 } 134 135 @Override 136 public String getName() { 137 return "endMargin"; 138 } 139 }; 140 public final double getEndMargin() { return endMargin.getValue(); } 141 public final void setEndMargin(double value) { endMargin.setValue(value); } 142 public final DoubleProperty endMarginProperty() { return endMargin; } 143 144 /** If this is true then half the space between ticks is left at the start 145 * and end 146 */ 147 private BooleanProperty gapStartAndEnd = new StyleableBooleanProperty(true) { 148 @Override protected void invalidated() { 149 requestAxisLayout(); 150 } 151 152 153 @Override public CssMetaData<CategoryAxis,Boolean> getCssMetaData() { 154 return StyleableProperties.GAP_START_AND_END; 155 } 156 157 @Override 158 public Object getBean() { 159 return CategoryAxis.this; 160 } 161 162 @Override 163 public String getName() { 164 return "gapStartAndEnd"; 165 } 166 }; 167 public final boolean isGapStartAndEnd() { return gapStartAndEnd.getValue(); } 168 public final void setGapStartAndEnd(boolean value) { gapStartAndEnd.setValue(value); } 169 public final BooleanProperty gapStartAndEndProperty() { return gapStartAndEnd; } 170 171 private ObjectProperty<ObservableList<String>> categories = new ObjectPropertyBase<ObservableList<String>>() { 172 ObservableList<String> old; 173 @Override protected void invalidated() { 174 if (getDuplicate() != null) { 175 throw new IllegalArgumentException("Duplicate category added; "+getDuplicate()+" already present"); 176 } 177 final ObservableList<String> newItems = get(); 178 if (old != newItems) { 179 // Add and remove listeners 180 if (old != null) old.removeListener(itemsListener); 181 if (newItems != null) newItems.addListener(itemsListener); 182 old = newItems; 183 } 184 } 185 186 @Override 187 public Object getBean() { 188 return CategoryAxis.this; 189 } 190 191 @Override 192 public String getName() { 193 return "categories"; 194 } 195 }; 196 197 /** 198 * The ordered list of categories plotted on this axis. This is set automatically 199 * based on the charts data if autoRanging is true. If the application sets the categories 200 * then auto ranging is turned off. If there is an attempt to add duplicate entry into this list, 201 * an {@link IllegalArgumentException} is thrown. 202 */ 203 public final void setCategories(ObservableList<String> value) { 204 categories.set(value); 205 if (!changeIsLocal) { 206 setAutoRanging(false); 207 allDataCategories.clear(); 208 allDataCategories.addAll(getCategories()); 209 } 210 requestAxisLayout(); 211 } 212 213 private void checkAndRemoveDuplicates(String category) { 214 if (getDuplicate() != null) { 215 getCategories().remove(category); 216 throw new IllegalArgumentException("Duplicate category ; "+category+" already present"); 217 } 218 } 219 220 private String getDuplicate() { 221 if (getCategories() != null) { 222 for (int i = 0; i < getCategories().size(); i++) { 223 for (int j = 0; j < getCategories().size(); j++) { 224 if (getCategories().get(i).equals(getCategories().get(j)) && i != j) { 225 return getCategories().get(i); 226 } 227 } 228 } 229 } 230 return null; 231 } 232 /** 233 * Returns a {@link ObservableList} of categories plotted on this axis. 234 * 235 * @return ObservableList of categories for this axis. 236 * @see #categories 237 */ 238 public final ObservableList<String> getCategories() { 239 return categories.get(); 240 } 241 242 /** This is the gap between one category and the next along this axis */ 243 private final ReadOnlyDoubleWrapper categorySpacing = new ReadOnlyDoubleWrapper(this, "categorySpacing", 1); 244 public final double getCategorySpacing() { 245 return categorySpacing.get(); 246 } 247 public final ReadOnlyDoubleProperty categorySpacingProperty() { 248 return categorySpacing.getReadOnlyProperty(); 249 } 250 251 // -------------- CONSTRUCTORS ------------------------------------------------------------------------------------- 252 253 /** 254 * Create a auto-ranging category axis with an empty list of categories. 255 */ 256 public CategoryAxis() { 257 changeIsLocal = true; 258 setCategories(FXCollections.<String>observableArrayList()); 259 changeIsLocal = false; 260 } 261 262 /** 263 * Create a category axis with the given categories. This will not auto-range but be fixed with the given categories. 264 * 265 * @param categories List of the categories for this axis 266 */ 267 public CategoryAxis(ObservableList<String> categories) { 268 setCategories(categories); 269 } 270 271 // -------------- PRIVATE METHODS ---------------------------------------------------------------------------------- 272 273 private double calculateNewSpacing(double length, List<String> categories) { 274 final Side side = getEffectiveSide(); 275 double newCategorySpacing = 1; 276 if(categories != null) { 277 double bVal = (isGapStartAndEnd() ? (categories.size()) : (categories.size() - 1)); 278 // RT-14092 flickering : check if bVal is 0 279 newCategorySpacing = (bVal == 0) ? 1 : (length-getStartMargin()-getEndMargin()) / bVal; 280 } 281 // if autoranging is off setRange is not called so we update categorySpacing 282 if (!isAutoRanging()) categorySpacing.set(newCategorySpacing); 283 return newCategorySpacing; 284 } 285 286 private double calculateNewFirstPos(double length, double catSpacing) { 287 final Side side = getEffectiveSide(); 288 double newPos = 1; 289 double offset = ((isGapStartAndEnd()) ? (catSpacing / 2) : (0)); 290 if (side.isHorizontal()) { 291 newPos = 0 + getStartMargin() + offset; 292 } else { // VERTICAL 293 newPos = length - getStartMargin() - offset; 294 } 295 // if autoranging is off setRange is not called so we update first cateogory pos. 296 if (!isAutoRanging()) firstCategoryPos.set(newPos); 297 return newPos; 298 } 299 300 // -------------- PROTECTED METHODS -------------------------------------------------------------------------------- 301 302 /** 303 * Called to get the current axis range. 304 * 305 * @return A range object that can be passed to setRange() and calculateTickValues() 306 */ 307 @Override protected Object getRange() { 308 return new Object[]{ getCategories(), categorySpacing.get(), firstCategoryPos.get(), getEffectiveTickLabelRotation() }; 309 } 310 311 /** 312 * Called to set the current axis range to the given range. If isAnimating() is true then this method should 313 * animate the range to the new range. 314 * 315 * @param range A range object returned from autoRange() 316 * @param animate If true animate the change in range 317 */ 318 @Override protected void setRange(Object range, boolean animate) { 319 Object[] rangeArray = (Object[]) range; 320 @SuppressWarnings({"unchecked"}) List<String> categories = (List<String>)rangeArray[0]; 321 // if (categories.isEmpty()) new java.lang.Throwable().printStackTrace(); 322 double newCategorySpacing = (Double)rangeArray[1]; 323 double newFirstCategoryPos = (Double)rangeArray[2]; 324 setEffectiveTickLabelRotation((Double)rangeArray[3]); 325 326 changeIsLocal = true; 327 setCategories(FXCollections.<String>observableArrayList(categories)); 328 changeIsLocal = false; 329 if (animate) { 330 animator.stop(currentAnimationID); 331 currentAnimationID = animator.animate( 332 new KeyFrame(Duration.ZERO, 333 new KeyValue(firstCategoryPos, firstCategoryPos.get()), 334 new KeyValue(categorySpacing, categorySpacing.get()) 335 ), 336 new KeyFrame(Duration.millis(1000), 337 new KeyValue(firstCategoryPos,newFirstCategoryPos), 338 new KeyValue(categorySpacing,newCategorySpacing) 339 ) 340 ); 341 } else { 342 categorySpacing.set(newCategorySpacing); 343 firstCategoryPos.set(newFirstCategoryPos); 344 } 345 } 346 347 /** 348 * This calculates the categories based on the data provided to invalidateRange() method. This must not 349 * effect the state of the axis, changing any properties of the axis. Any results of the auto-ranging should be 350 * returned in the range object. This will we passed to setRange() if it has been decided to adopt this range for 351 * this axis. 352 * 353 * @param length The length of the axis in screen coordinates 354 * @return Range information, this is implementation dependent 355 */ 356 @Override protected Object autoRange(double length) { 357 final Side side = getEffectiveSide(); 358 // TODO check if we can display all categories 359 final double newCategorySpacing = calculateNewSpacing(length,allDataCategories); 360 final double newFirstPos = calculateNewFirstPos(length, newCategorySpacing); 361 double tickLabelRotation = getTickLabelRotation(); 362 if (length >= 0) { 363 double requiredLengthToDisplay = calculateRequiredSize(side.isVertical(), tickLabelRotation); 364 if (requiredLengthToDisplay > length) { 365 // try to rotate the text to increase the density 366 if (side.isHorizontal() && tickLabelRotation != 90) { 367 tickLabelRotation = 90; 368 } 369 if (side.isVertical() && tickLabelRotation != 0) { 370 tickLabelRotation = 0; 371 } 372 } 373 } 374 return new Object[]{allDataCategories, newCategorySpacing, newFirstPos, tickLabelRotation}; 375 } 376 377 private double calculateRequiredSize(boolean axisVertical, double tickLabelRotation) { 378 // Calculate the max space required between categories labels 379 double maxReqTickGap = 0; 380 double last = 0; 381 boolean first = true; 382 for (String category: allDataCategories) { 383 Dimension2D textSize = measureTickMarkSize(category, tickLabelRotation); 384 double size = (axisVertical || (tickLabelRotation != 0)) ? textSize.getHeight() : textSize.getWidth(); 385 // TODO better handle calculations for rotated text, overlapping text etc 386 if (first) { 387 first = false; 388 last = size/2; 389 } else { 390 maxReqTickGap = Math.max(maxReqTickGap, last + 6 + (size/2) ); 391 } 392 } 393 return getStartMargin() + maxReqTickGap*allDataCategories.size() + getEndMargin(); 394 } 395 396 /** 397 * Calculate a list of all the data values for each tick mark in range 398 * 399 * @param length The length of the axis in display units 400 * @return A list of tick marks that fit along the axis if it was the given length 401 */ 402 @Override protected List<String> calculateTickValues(double length, Object range) { 403 Object[] rangeArray = (Object[]) range; 404 //noinspection unchecked 405 return (List<String>)rangeArray[0]; 406 } 407 408 /** 409 * Get the string label name for a tick mark with the given value 410 * 411 * @param value The value to format into a tick label string 412 * @return A formatted string for the given value 413 */ 414 @Override protected String getTickMarkLabel(String value) { 415 // TODO use formatter 416 return value; 417 } 418 419 /** 420 * Measure the size of the label for given tick mark value. This uses the font that is set for the tick marks 421 * 422 * @param value tick mark value 423 * @param range range to use during calculations 424 * @return size of tick mark label for given value 425 */ 426 @Override protected Dimension2D measureTickMarkSize(String value, Object range) { 427 final Object[] rangeArray = (Object[]) range; 428 final double tickLabelRotation = (Double)rangeArray[3]; 429 return measureTickMarkSize(value,tickLabelRotation); 430 } 431 432 // -------------- METHODS ------------------------------------------------------------------------------------------ 433 434 /** 435 * Called when data has changed and the range may not be valid any more. This is only called by the chart if 436 * isAutoRanging() returns true. If we are auto ranging it will cause layout to be requested and auto ranging to 437 * happen on next layout pass. 438 * 439 * @param data The current set of all data that needs to be plotted on this axis 440 */ 441 @Override public void invalidateRange(List<String> data) { 442 super.invalidateRange(data); 443 // Create unique set of category names 444 List<String> categoryNames = new ArrayList<String>(); 445 categoryNames.addAll(allDataCategories); 446 //RT-21141 allDataCategories needs to be updated based on data - 447 // and should maintain the order it originally had for the categories already present. 448 // and remove categories not present in data 449 for(String cat : allDataCategories) { 450 if (!data.contains(cat)) categoryNames.remove(cat); 451 } 452 // add any new category found in data 453 // for(String cat : data) { 454 for (int i = 0; i < data.size(); i++) { 455 int len = categoryNames.size(); 456 if (!categoryNames.contains(data.get(i))) categoryNames.add((i > len) ? len : i, data.get(i)); 457 } 458 allDataCategories.clear(); 459 allDataCategories.addAll(categoryNames); 460 } 461 462 final List<String> getAllDataCategories() { 463 return allDataCategories; 464 } 465 466 /** 467 * Get the display position along this axis for a given value. 468 * 469 * If the value is not equal to any of the categories, Double.NaN is returned 470 * 471 * @param value The data value to work out display position for 472 * @return display position or Double.NaN if value not one of the categories 473 */ 474 @Override public double getDisplayPosition(String value) { 475 // find index of value 476 final ObservableList<String> cat = getCategories(); 477 if (!cat.contains(value)) { 478 return Double.NaN; 479 } 480 if (getEffectiveSide().isHorizontal()) { 481 return firstCategoryPos.get() + cat.indexOf(value) * categorySpacing.get(); 482 } else { 483 return firstCategoryPos.get() + cat.indexOf(value) * categorySpacing.get() * -1; 484 } 485 } 486 487 /** 488 * Get the data value for the given display position on this axis. If the axis 489 * is a CategoryAxis this will be the nearest value. 490 * 491 * @param displayPosition A pixel position on this axis 492 * @return the nearest data value to the given pixel position or 493 * null if not on axis; 494 */ 495 @Override public String getValueForDisplay(double displayPosition) { 496 if (getEffectiveSide().isHorizontal()) { 497 if (displayPosition < 0 || displayPosition > getWidth()) return null; 498 double d = (displayPosition - firstCategoryPos.get()) / categorySpacing.get(); 499 return toRealValue(d); 500 } else { // VERTICAL 501 if (displayPosition < 0 || displayPosition > getHeight()) return null; 502 double d = (displayPosition - firstCategoryPos.get()) / (categorySpacing.get() * -1); 503 return toRealValue(d); 504 } 505 } 506 507 /** 508 * Checks if the given value is plottable on this axis 509 * 510 * @param value The value to check if its on axis 511 * @return true if the given value is plottable on this axis 512 */ 513 @Override public boolean isValueOnAxis(String value) { 514 return getCategories().indexOf("" + value) != -1; 515 } 516 517 /** 518 * All axis values must be representable by some numeric value. This gets the numeric value for a given data value. 519 * 520 * @param value The data value to convert 521 * @return Numeric value for the given data value 522 */ 523 @Override public double toNumericValue(String value) { 524 return getCategories().indexOf(value); 525 } 526 527 /** 528 * All axis values must be representable by some numeric value. This gets the data value for a given numeric value. 529 * 530 * @param value The numeric value to convert 531 * @return Data value for given numeric value 532 */ 533 @Override public String toRealValue(double value) { 534 int index = (int)Math.round(value); 535 List<String> categories = getCategories(); 536 if (index >= 0 && index < categories.size()) { 537 return getCategories().get(index); 538 } else { 539 return null; 540 } 541 } 542 543 /** 544 * Get the display position of the zero line along this axis. As there is no concept of zero on a CategoryAxis 545 * this is always Double.NaN. 546 * 547 * @return always Double.NaN for CategoryAxis 548 */ 549 @Override public double getZeroPosition() { 550 return Double.NaN; 551 } 552 553 // -------------- STYLESHEET HANDLING ------------------------------------------------------------------------------ 554 555 private static class StyleableProperties { 556 private static final CssMetaData<CategoryAxis,Number> START_MARGIN = 557 new CssMetaData<CategoryAxis,Number>("-fx-start-margin", 558 SizeConverter.getInstance(), 5.0) { 559 560 @Override 561 public boolean isSettable(CategoryAxis n) { 562 return n.startMargin == null || !n.startMargin.isBound(); 563 } 564 565 @Override 566 public StyleableProperty<Number> getStyleableProperty(CategoryAxis n) { 567 return (StyleableProperty<Number>)(WritableValue<Number>)n.startMarginProperty(); 568 } 569 }; 570 571 private static final CssMetaData<CategoryAxis,Number> END_MARGIN = 572 new CssMetaData<CategoryAxis,Number>("-fx-end-margin", 573 SizeConverter.getInstance(), 5.0) { 574 575 @Override 576 public boolean isSettable(CategoryAxis n) { 577 return n.endMargin == null || !n.endMargin.isBound(); 578 } 579 580 @Override 581 public StyleableProperty<Number> getStyleableProperty(CategoryAxis n) { 582 return (StyleableProperty<Number>)(WritableValue<Number>)n.endMarginProperty(); 583 } 584 }; 585 586 private static final CssMetaData<CategoryAxis,Boolean> GAP_START_AND_END = 587 new CssMetaData<CategoryAxis,Boolean>("-fx-gap-start-and-end", 588 BooleanConverter.getInstance(), Boolean.TRUE) { 589 590 @Override 591 public boolean isSettable(CategoryAxis n) { 592 return n.gapStartAndEnd == null || !n.gapStartAndEnd.isBound(); 593 } 594 595 @Override 596 public StyleableProperty<Boolean> getStyleableProperty(CategoryAxis n) { 597 return (StyleableProperty<Boolean>)(WritableValue<Boolean>)n.gapStartAndEndProperty(); 598 } 599 }; 600 601 private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES; 602 static { 603 final List<CssMetaData<? extends Styleable, ?>> styleables = 604 new ArrayList<CssMetaData<? extends Styleable, ?>>(Axis.getClassCssMetaData()); 605 styleables.add(START_MARGIN); 606 styleables.add(END_MARGIN); 607 styleables.add(GAP_START_AND_END); 608 STYLEABLES = Collections.unmodifiableList(styleables); 609 } 610 } 611 612 /** 613 * @return The CssMetaData associated with this class, which may include the 614 * CssMetaData of its superclasses. 615 * @since JavaFX 8.0 616 */ 617 public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() { 618 return StyleableProperties.STYLEABLES; 619 } 620 621 /** 622 * {@inheritDoc} 623 * @since JavaFX 8.0 624 */ 625 @Override 626 public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() { 627 return getClassCssMetaData(); 628 } 629 630 } 631