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