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