1 /* 2 * Copyright (c) 2010, 2017, 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 * @param value the ordered list of categories plotted on this axis 203 */ 204 public final void setCategories(ObservableList<String> value) { 205 categories.set(value); 206 if (!changeIsLocal) { 207 setAutoRanging(false); 208 allDataCategories.clear(); 209 allDataCategories.addAll(getCategories()); 210 } 211 requestAxisLayout(); 212 } 213 214 private void checkAndRemoveDuplicates(String category) { 215 if (getDuplicate() != null) { 216 getCategories().remove(category); 217 throw new IllegalArgumentException("Duplicate category ; "+category+" already present"); 218 } 219 } 220 221 private String getDuplicate() { 222 if (getCategories() != null) { 223 for (int i = 0; i < getCategories().size(); i++) { 224 for (int j = 0; j < getCategories().size(); j++) { 225 if (getCategories().get(i).equals(getCategories().get(j)) && i != j) { 226 return getCategories().get(i); 227 } 228 } 229 } 230 } 231 return null; 232 } 233 /** 234 * Returns a {@link ObservableList} of categories plotted on this axis. 235 * 236 * @return ObservableList of categories for this axis. 237 * @see #categories 238 */ 239 public final ObservableList<String> getCategories() { 240 return categories.get(); 241 } 242 243 /** This is the gap between one category and the next along this axis */ 244 private final ReadOnlyDoubleWrapper categorySpacing = new ReadOnlyDoubleWrapper(this, "categorySpacing", 1); 245 public final double getCategorySpacing() { 246 return categorySpacing.get(); 247 } 248 public final ReadOnlyDoubleProperty categorySpacingProperty() { 249 return categorySpacing.getReadOnlyProperty(); 250 } 251 252 // -------------- CONSTRUCTORS ------------------------------------------------------------------------------------- 253 254 /** 255 * Create a auto-ranging category axis with an empty list of categories. 256 */ 257 public CategoryAxis() { 258 changeIsLocal = true; 259 setCategories(FXCollections.<String>observableArrayList()); 260 changeIsLocal = false; 261 } 262 263 /** 264 * Create a category axis with the given categories. This will not auto-range but be fixed with the given categories. 265 * 266 * @param categories List of the categories for this axis 267 */ 268 public CategoryAxis(ObservableList<String> categories) { 269 setCategories(categories); 270 } 271 272 // -------------- PRIVATE METHODS ---------------------------------------------------------------------------------- 273 274 private double calculateNewSpacing(double length, List<String> categories) { 275 final Side side = getEffectiveSide(); 276 double newCategorySpacing = 1; 277 if(categories != null) { 278 double bVal = (isGapStartAndEnd() ? (categories.size()) : (categories.size() - 1)); 279 // RT-14092 flickering : check if bVal is 0 280 newCategorySpacing = (bVal == 0) ? 1 : (length-getStartMargin()-getEndMargin()) / bVal; 281 } 282 // if autoranging is off setRange is not called so we update categorySpacing 283 if (!isAutoRanging()) categorySpacing.set(newCategorySpacing); 284 return newCategorySpacing; 285 } 286 287 private double calculateNewFirstPos(double length, double catSpacing) { 288 final Side side = getEffectiveSide(); 289 double newPos = 1; 290 double offset = ((isGapStartAndEnd()) ? (catSpacing / 2) : (0)); 291 if (side.isHorizontal()) { 292 newPos = 0 + getStartMargin() + offset; 293 } else { // VERTICAL 294 newPos = length - getStartMargin() - offset; 295 } 296 // if autoranging is off setRange is not called so we update first cateogory pos. 297 if (!isAutoRanging()) firstCategoryPos.set(newPos); 298 return newPos; 299 } 300 301 // -------------- PROTECTED METHODS -------------------------------------------------------------------------------- 302 303 /** 304 * Called to get the current axis range. 305 * 306 * @return A range object that can be passed to setRange() and calculateTickValues() 307 */ 308 @Override protected Object getRange() { 309 return new Object[]{ getCategories(), categorySpacing.get(), firstCategoryPos.get(), getEffectiveTickLabelRotation() }; 310 } 311 312 /** 313 * Called to set the current axis range to the given range. If isAnimating() is true then this method should 314 * animate the range to the new range. 315 * 316 * @param range A range object returned from autoRange() 317 * @param animate If true animate the change in range 318 */ 319 @Override protected void setRange(Object range, boolean animate) { 320 Object[] rangeArray = (Object[]) range; 321 @SuppressWarnings({"unchecked"}) List<String> categories = (List<String>)rangeArray[0]; 322 // if (categories.isEmpty()) new java.lang.Throwable().printStackTrace(); 323 double newCategorySpacing = (Double)rangeArray[1]; 324 double newFirstCategoryPos = (Double)rangeArray[2]; 325 setEffectiveTickLabelRotation((Double)rangeArray[3]); 326 327 changeIsLocal = true; 328 setCategories(FXCollections.<String>observableArrayList(categories)); 329 changeIsLocal = false; 330 if (animate) { 331 animator.stop(currentAnimationID); 332 currentAnimationID = animator.animate( 333 new KeyFrame(Duration.ZERO, 334 new KeyValue(firstCategoryPos, firstCategoryPos.get()), 335 new KeyValue(categorySpacing, categorySpacing.get()) 336 ), 337 new KeyFrame(Duration.millis(1000), 338 new KeyValue(firstCategoryPos,newFirstCategoryPos), 339 new KeyValue(categorySpacing,newCategorySpacing) 340 ) 341 ); 342 } else { 343 categorySpacing.set(newCategorySpacing); 344 firstCategoryPos.set(newFirstCategoryPos); 345 } 346 } 347 348 /** 349 * This calculates the categories based on the data provided to invalidateRange() method. This must not 350 * effect the state of the axis, changing any properties of the axis. Any results of the auto-ranging should be 351 * returned in the range object. This will we passed to setRange() if it has been decided to adopt this range for 352 * this axis. 353 * 354 * @param length The length of the axis in screen coordinates 355 * @return Range information, this is implementation dependent 356 */ 357 @Override protected Object autoRange(double length) { 358 final Side side = getEffectiveSide(); 359 // TODO check if we can display all categories 360 final double newCategorySpacing = calculateNewSpacing(length,allDataCategories); 361 final double newFirstPos = calculateNewFirstPos(length, newCategorySpacing); 362 double tickLabelRotation = getTickLabelRotation(); 363 if (length >= 0) { 364 double requiredLengthToDisplay = calculateRequiredSize(side.isVertical(), tickLabelRotation); 365 if (requiredLengthToDisplay > length) { 366 // try to rotate the text to increase the density 367 if (side.isHorizontal() && tickLabelRotation != 90) { 368 tickLabelRotation = 90; 369 } 370 if (side.isVertical() && tickLabelRotation != 0) { 371 tickLabelRotation = 0; 372 } 373 } 374 } 375 return new Object[]{allDataCategories, newCategorySpacing, newFirstPos, tickLabelRotation}; 376 } 377 378 private double calculateRequiredSize(boolean axisVertical, double tickLabelRotation) { 379 // Calculate the max space required between categories labels 380 double maxReqTickGap = 0; 381 double last = 0; 382 boolean first = true; 383 for (String category: allDataCategories) { 384 Dimension2D textSize = measureTickMarkSize(category, tickLabelRotation); 385 double size = (axisVertical || (tickLabelRotation != 0)) ? textSize.getHeight() : textSize.getWidth(); 386 // TODO better handle calculations for rotated text, overlapping text etc 387 if (first) { 388 first = false; 389 last = size/2; 390 } else { 391 maxReqTickGap = Math.max(maxReqTickGap, last + 6 + (size/2) ); 392 } 393 } 394 return getStartMargin() + maxReqTickGap*allDataCategories.size() + getEndMargin(); 395 } 396 397 /** 398 * Calculate a list of all the data values for each tick mark in range 399 * 400 * @param length The length of the axis in display units 401 * @return A list of tick marks that fit along the axis if it was the given length 402 */ 403 @Override protected List<String> calculateTickValues(double length, Object range) { 404 Object[] rangeArray = (Object[]) range; 405 //noinspection unchecked 406 return (List<String>)rangeArray[0]; 407 } 408 409 /** 410 * Get the string label name for a tick mark with the given value 411 * 412 * @param value The value to format into a tick label string 413 * @return A formatted string for the given value 414 */ 415 @Override protected String getTickMarkLabel(String value) { 416 // TODO use formatter 417 return value; 418 } 419 420 /** 421 * Measure the size of the label for given tick mark value. This uses the font that is set for the tick marks 422 * 423 * @param value tick mark value 424 * @param range range to use during calculations 425 * @return size of tick mark label for given value 426 */ 427 @Override protected Dimension2D measureTickMarkSize(String value, Object range) { 428 final Object[] rangeArray = (Object[]) range; 429 final double tickLabelRotation = (Double)rangeArray[3]; 430 return measureTickMarkSize(value,tickLabelRotation); 431 } 432 433 // -------------- METHODS ------------------------------------------------------------------------------------------ 434 435 /** 436 * Called when data has changed and the range may not be valid any more. This is only called by the chart if 437 * isAutoRanging() returns true. If we are auto ranging it will cause layout to be requested and auto ranging to 438 * happen on next layout pass. 439 * 440 * @param data The current set of all data that needs to be plotted on this axis 441 */ 442 @Override public void invalidateRange(List<String> data) { 443 super.invalidateRange(data); 444 // Create unique set of category names 445 List<String> categoryNames = new ArrayList<String>(); 446 categoryNames.addAll(allDataCategories); 447 //RT-21141 allDataCategories needs to be updated based on data - 448 // and should maintain the order it originally had for the categories already present. 449 // and remove categories not present in data 450 for(String cat : allDataCategories) { 451 if (!data.contains(cat)) categoryNames.remove(cat); 452 } 453 // add any new category found in data 454 // for(String cat : data) { 455 for (int i = 0; i < data.size(); i++) { 456 int len = categoryNames.size(); 457 if (!categoryNames.contains(data.get(i))) categoryNames.add((i > len) ? len : i, data.get(i)); 458 } 459 allDataCategories.clear(); 460 allDataCategories.addAll(categoryNames); 461 } 462 463 final List<String> getAllDataCategories() { 464 return allDataCategories; 465 } 466 467 /** 468 * Get the display position along this axis for a given value. 469 * 470 * If the value is not equal to any of the categories, Double.NaN is returned 471 * 472 * @param value The data value to work out display position for 473 * @return display position or Double.NaN if value not one of the categories 474 */ 475 @Override public double getDisplayPosition(String value) { 476 // find index of value 477 final ObservableList<String> cat = getCategories(); 478 if (!cat.contains(value)) { 479 return Double.NaN; 480 } 481 if (getEffectiveSide().isHorizontal()) { 482 return firstCategoryPos.get() + cat.indexOf(value) * categorySpacing.get(); 483 } else { 484 return firstCategoryPos.get() + cat.indexOf(value) * categorySpacing.get() * -1; 485 } 486 } 487 488 /** 489 * Get the data value for the given display position on this axis. If the axis 490 * is a CategoryAxis this will be the nearest value. 491 * 492 * @param displayPosition A pixel position on this axis 493 * @return the nearest data value to the given pixel position or 494 * null if not on axis; 495 */ 496 @Override public String getValueForDisplay(double displayPosition) { 497 if (getEffectiveSide().isHorizontal()) { 498 if (displayPosition < 0 || displayPosition > getWidth()) return null; 499 double d = (displayPosition - firstCategoryPos.get()) / categorySpacing.get(); 500 return toRealValue(d); 501 } else { // VERTICAL 502 if (displayPosition < 0 || displayPosition > getHeight()) return null; 503 double d = (displayPosition - firstCategoryPos.get()) / (categorySpacing.get() * -1); 504 return toRealValue(d); 505 } 506 } 507 508 /** 509 * Checks if the given value is plottable on this axis 510 * 511 * @param value The value to check if its on axis 512 * @return true if the given value is plottable on this axis 513 */ 514 @Override public boolean isValueOnAxis(String value) { 515 return getCategories().indexOf("" + value) != -1; 516 } 517 518 /** 519 * All axis values must be representable by some numeric value. This gets the numeric value for a given data value. 520 * 521 * @param value The data value to convert 522 * @return Numeric value for the given data value 523 */ 524 @Override public double toNumericValue(String value) { 525 return getCategories().indexOf(value); 526 } 527 528 /** 529 * All axis values must be representable by some numeric value. This gets the data value for a given numeric value. 530 * 531 * @param value The numeric value to convert 532 * @return Data value for given numeric value 533 */ 534 @Override public String toRealValue(double value) { 535 int index = (int)Math.round(value); 536 List<String> categories = getCategories(); 537 if (index >= 0 && index < categories.size()) { 538 return getCategories().get(index); 539 } else { 540 return null; 541 } 542 } 543 544 /** 545 * Get the display position of the zero line along this axis. As there is no concept of zero on a CategoryAxis 546 * this is always Double.NaN. 547 * 548 * @return always Double.NaN for CategoryAxis 549 */ 550 @Override public double getZeroPosition() { 551 return Double.NaN; 552 } 553 554 // -------------- STYLESHEET HANDLING ------------------------------------------------------------------------------ 555 556 private static class StyleableProperties { 557 private static final CssMetaData<CategoryAxis,Number> START_MARGIN = 558 new CssMetaData<CategoryAxis,Number>("-fx-start-margin", 559 SizeConverter.getInstance(), 5.0) { 560 561 @Override 562 public boolean isSettable(CategoryAxis n) { 563 return n.startMargin == null || !n.startMargin.isBound(); 564 } 565 566 @Override 567 public StyleableProperty<Number> getStyleableProperty(CategoryAxis n) { 568 return (StyleableProperty<Number>)(WritableValue<Number>)n.startMarginProperty(); 569 } 570 }; 571 572 private static final CssMetaData<CategoryAxis,Number> END_MARGIN = 573 new CssMetaData<CategoryAxis,Number>("-fx-end-margin", 574 SizeConverter.getInstance(), 5.0) { 575 576 @Override 577 public boolean isSettable(CategoryAxis n) { 578 return n.endMargin == null || !n.endMargin.isBound(); 579 } 580 581 @Override 582 public StyleableProperty<Number> getStyleableProperty(CategoryAxis n) { 583 return (StyleableProperty<Number>)(WritableValue<Number>)n.endMarginProperty(); 584 } 585 }; 586 587 private static final CssMetaData<CategoryAxis,Boolean> GAP_START_AND_END = 588 new CssMetaData<CategoryAxis,Boolean>("-fx-gap-start-and-end", 589 BooleanConverter.getInstance(), Boolean.TRUE) { 590 591 @Override 592 public boolean isSettable(CategoryAxis n) { 593 return n.gapStartAndEnd == null || !n.gapStartAndEnd.isBound(); 594 } 595 596 @Override 597 public StyleableProperty<Boolean> getStyleableProperty(CategoryAxis n) { 598 return (StyleableProperty<Boolean>)(WritableValue<Boolean>)n.gapStartAndEndProperty(); 599 } 600 }; 601 602 private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES; 603 static { 604 final List<CssMetaData<? extends Styleable, ?>> styleables = 605 new ArrayList<CssMetaData<? extends Styleable, ?>>(Axis.getClassCssMetaData()); 606 styleables.add(START_MARGIN); 607 styleables.add(END_MARGIN); 608 styleables.add(GAP_START_AND_END); 609 STYLEABLES = Collections.unmodifiableList(styleables); 610 } 611 } 612 613 /** 614 * @return The CssMetaData associated with this class, which may include the 615 * CssMetaData of its superclasses. 616 * @since JavaFX 8.0 617 */ 618 public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() { 619 return StyleableProperties.STYLEABLES; 620 } 621 622 /** 623 * {@inheritDoc} 624 * @since JavaFX 8.0 625 */ 626 @Override 627 public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() { 628 return getClassCssMetaData(); 629 } 630 631 } 632