1 /* 2 * Copyright (c) 2010, 2014, 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 // change text to vertical 366 tickLabelRotation = 90; 367 } 368 } 369 return new Object[]{allDataCategories, newCategorySpacing, newFirstPos, tickLabelRotation}; 370 } 371 372 private double calculateRequiredSize(boolean axisVertical, double tickLabelRotation) { 373 double requiredLengthToDisplay = Double.MAX_VALUE; 374 // Calculate the max space required between categories labels 375 double maxReqTickGap = 0; 376 double last = 0; 377 boolean first = true; 378 for (String category: allDataCategories) { 379 Dimension2D textSize = measureTickMarkSize(category, tickLabelRotation); 380 double size = (axisVertical || (tickLabelRotation != 0)) ? textSize.getHeight() : textSize.getWidth(); 381 // TODO better handle calculations for rotated text, overlapping text etc 382 if (first) { 383 first = false; 384 last = size/2; 385 } else { 386 maxReqTickGap = Math.max(maxReqTickGap, last + 6 + (size/2) ); 387 } 388 } 389 return getStartMargin() + maxReqTickGap*allDataCategories.size() + getEndMargin(); 390 } 391 392 /** 393 * Calculate a list of all the data values for each tick mark in range 394 * 395 * @param length The length of the axis in display units 396 * @return A list of tick marks that fit along the axis if it was the given length 397 */ 398 @Override protected List<String> calculateTickValues(double length, Object range) { 399 Object[] rangeArray = (Object[]) range; 400 //noinspection unchecked 401 return (List<String>)rangeArray[0]; 402 } 403 404 /** 405 * Get the string label name for a tick mark with the given value 406 * 407 * @param value The value to format into a tick label string 408 * @return A formatted string for the given value 409 */ 410 @Override protected String getTickMarkLabel(String value) { 411 // TODO use formatter 412 return value; 413 } 414 415 /** 416 * Measure the size of the label for given tick mark value. This uses the font that is set for the tick marks 417 * 418 * @param value tick mark value 419 * @param range range to use during calculations 420 * @return size of tick mark label for given value 421 */ 422 @Override protected Dimension2D measureTickMarkSize(String value, Object range) { 423 final Object[] rangeArray = (Object[]) range; 424 final double tickLabelRotation = (Double)rangeArray[3]; 425 return measureTickMarkSize(value,tickLabelRotation); 426 } 427 428 // -------------- METHODS ------------------------------------------------------------------------------------------ 429 430 /** 431 * Called when data has changed and the range may not be valid any more. This is only called by the chart if 432 * isAutoRanging() returns true. If we are auto ranging it will cause layout to be requested and auto ranging to 433 * happen on next layout pass. 434 * 435 * @param data The current set of all data that needs to be plotted on this axis 436 */ 437 @Override public void invalidateRange(List<String> data) { 438 super.invalidateRange(data); 439 // Create unique set of category names 440 List<String> categoryNames = new ArrayList<String>(); 441 categoryNames.addAll(allDataCategories); 442 //RT-21141 allDataCategories needs to be updated based on data - 443 // and should maintain the order it originally had for the categories already present. 444 // and remove categories not present in data 445 for(String cat : allDataCategories) { 446 if (!data.contains(cat)) categoryNames.remove(cat); 447 } 448 // add any new category found in data 449 // for(String cat : data) { 450 for (int i = 0; i < data.size(); i++) { 451 int len = categoryNames.size(); 452 if (!categoryNames.contains(data.get(i))) categoryNames.add((i > len) ? len : i, data.get(i)); 453 } 454 allDataCategories.clear(); 455 allDataCategories.addAll(categoryNames); 456 } 457 458 final List<String> getAllDataCategories() { 459 return allDataCategories; 460 } 461 462 /** 463 * Get the display position along this axis for a given value. 464 * 465 * If the value is not equal to any of the categories, Double.NaN is returned 466 * 467 * @param value The data value to work out display position for 468 * @return display position or Double.NaN if value not one of the categories 469 */ 470 @Override public double getDisplayPosition(String value) { 471 // find index of value 472 final ObservableList<String> cat = getCategories(); 473 if (!cat.contains(value)) { 474 return Double.NaN; 475 } 476 if (getEffectiveSide().isHorizontal()) { 477 return firstCategoryPos.get() + cat.indexOf(value) * categorySpacing.get(); 478 } else { 479 return firstCategoryPos.get() + cat.indexOf(value) * categorySpacing.get() * -1; 480 } 481 } 482 483 /** 484 * Get the data value for the given display position on this axis. If the axis 485 * is a CategoryAxis this will be the nearest value. 486 * 487 * @param displayPosition A pixel position on this axis 488 * @return the nearest data value to the given pixel position or 489 * null if not on axis; 490 */ 491 @Override public String getValueForDisplay(double displayPosition) { 492 if (getEffectiveSide().isHorizontal()) { 493 if (displayPosition < 0 || displayPosition > getWidth()) return null; 494 double d = (displayPosition - firstCategoryPos.get()) / categorySpacing.get(); 495 return toRealValue(d); 496 } else { // VERTICAL 497 if (displayPosition < 0 || displayPosition > getHeight()) return null; 498 double d = (displayPosition - firstCategoryPos.get()) / (categorySpacing.get() * -1); 499 return toRealValue(d); 500 } 501 } 502 503 /** 504 * Checks if the given value is plottable on this axis 505 * 506 * @param value The value to check if its on axis 507 * @return true if the given value is plottable on this axis 508 */ 509 @Override public boolean isValueOnAxis(String value) { 510 return getCategories().indexOf("" + value) != -1; 511 } 512 513 /** 514 * All axis values must be representable by some numeric value. This gets the numeric value for a given data value. 515 * 516 * @param value The data value to convert 517 * @return Numeric value for the given data value 518 */ 519 @Override public double toNumericValue(String value) { 520 return getCategories().indexOf(value); 521 } 522 523 /** 524 * All axis values must be representable by some numeric value. This gets the data value for a given numeric value. 525 * 526 * @param value The numeric value to convert 527 * @return Data value for given numeric value 528 */ 529 @Override public String toRealValue(double value) { 530 int index = (int)Math.round(value); 531 List<String> categories = getCategories(); 532 if (index >= 0 && index < categories.size()) { 533 return getCategories().get(index); 534 } else { 535 return null; 536 } 537 } 538 539 /** 540 * Get the display position of the zero line along this axis. As there is no concept of zero on a CategoryAxis 541 * this is always Double.NaN. 542 * 543 * @return always Double.NaN for CategoryAxis 544 */ 545 @Override public double getZeroPosition() { 546 return Double.NaN; 547 } 548 549 // -------------- STYLESHEET HANDLING ------------------------------------------------------------------------------ 550 551 /** @treatAsPrivate implementation detail */ 552 private static class StyleableProperties { 553 private static final CssMetaData<CategoryAxis,Number> START_MARGIN = 554 new CssMetaData<CategoryAxis,Number>("-fx-start-margin", 555 SizeConverter.getInstance(), 5.0) { 556 557 @Override 558 public boolean isSettable(CategoryAxis n) { 559 return n.startMargin == null || !n.startMargin.isBound(); 560 } 561 562 @Override 563 public StyleableProperty<Number> getStyleableProperty(CategoryAxis n) { 564 return (StyleableProperty<Number>)(WritableValue<Number>)n.startMarginProperty(); 565 } 566 }; 567 568 private static final CssMetaData<CategoryAxis,Number> END_MARGIN = 569 new CssMetaData<CategoryAxis,Number>("-fx-end-margin", 570 SizeConverter.getInstance(), 5.0) { 571 572 @Override 573 public boolean isSettable(CategoryAxis n) { 574 return n.endMargin == null || !n.endMargin.isBound(); 575 } 576 577 @Override 578 public StyleableProperty<Number> getStyleableProperty(CategoryAxis n) { 579 return (StyleableProperty<Number>)(WritableValue<Number>)n.endMarginProperty(); 580 } 581 }; 582 583 private static final CssMetaData<CategoryAxis,Boolean> GAP_START_AND_END = 584 new CssMetaData<CategoryAxis,Boolean>("-fx-gap-start-and-end", 585 BooleanConverter.getInstance(), Boolean.TRUE) { 586 587 @Override 588 public boolean isSettable(CategoryAxis n) { 589 return n.gapStartAndEnd == null || !n.gapStartAndEnd.isBound(); 590 } 591 592 @Override 593 public StyleableProperty<Boolean> getStyleableProperty(CategoryAxis n) { 594 return (StyleableProperty<Boolean>)(WritableValue<Boolean>)n.gapStartAndEndProperty(); 595 } 596 }; 597 598 private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES; 599 static { 600 final List<CssMetaData<? extends Styleable, ?>> styleables = 601 new ArrayList<CssMetaData<? extends Styleable, ?>>(Axis.getClassCssMetaData()); 602 styleables.add(START_MARGIN); 603 styleables.add(END_MARGIN); 604 styleables.add(GAP_START_AND_END); 605 STYLEABLES = Collections.unmodifiableList(styleables); 606 } 607 } 608 609 /** 610 * @return The CssMetaData associated with this class, which may include the 611 * CssMetaData of its super classes. 612 * @since JavaFX 8.0 613 */ 614 public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() { 615 return StyleableProperties.STYLEABLES; 616 } 617 618 /** 619 * {@inheritDoc} 620 * @since JavaFX 8.0 621 */ 622 @Override 623 public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() { 624 return getClassCssMetaData(); 625 } 626 627 } 628