001/* ===========================================================
002 * JFreeChart : a free chart library for the Java(tm) platform
003 * ===========================================================
004 *
005 * (C) Copyright 2000-2020, by Object Refinery Limited and Contributors.
006 *
007 * Project Info:  http://www.jfree.org/jfreechart/index.html
008 *
009 * This library is free software; you can redistribute it and/or modify it
010 * under the terms of the GNU Lesser General Public License as published by
011 * the Free Software Foundation; either version 2.1 of the License, or
012 * (at your option) any later version.
013 *
014 * This library is distributed in the hope that it will be useful, but
015 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
016 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
017 * License for more details.
018 *
019 * You should have received a copy of the GNU Lesser General Public
020 * License along with this library; if not, write to the Free Software
021 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
022 * USA.
023 *
024 * [Oracle and Java are registered trademarks of Oracle and/or its affiliates. 
025 * Other names may be trademarks of their respective owners.]
026 *
027 * -----------------
028 * CategoryAxis.java
029 * -----------------
030 * (C) Copyright 2000-2020, by Object Refinery Limited and Contributors.
031 *
032 * Original Author:  David Gilbert;
033 * Contributor(s):   Pady Srinivasan (patch 1217634);
034 *                   Peter Kolb (patches 2497611 and 2603321);
035 *
036 */
037
038package org.jfree.chart.axis;
039
040import java.awt.Font;
041import java.awt.Graphics2D;
042import java.awt.Paint;
043import java.awt.RenderingHints;
044import java.awt.Shape;
045import java.awt.geom.Line2D;
046import java.awt.geom.Point2D;
047import java.awt.geom.Rectangle2D;
048import java.io.IOException;
049import java.io.ObjectInputStream;
050import java.io.ObjectOutputStream;
051import java.io.Serializable;
052import java.util.HashMap;
053import java.util.Iterator;
054import java.util.List;
055import java.util.Map;
056import java.util.Set;
057
058import org.jfree.chart.entity.CategoryLabelEntity;
059import org.jfree.chart.entity.EntityCollection;
060import org.jfree.chart.event.AxisChangeEvent;
061import org.jfree.chart.plot.CategoryPlot;
062import org.jfree.chart.plot.Plot;
063import org.jfree.chart.plot.PlotRenderingInfo;
064import org.jfree.chart.text.G2TextMeasurer;
065import org.jfree.chart.text.TextBlock;
066import org.jfree.chart.text.TextUtils;
067import org.jfree.chart.ui.RectangleEdge;
068import org.jfree.chart.ui.RectangleInsets;
069import org.jfree.chart.ui.Size2D;
070import org.jfree.chart.util.ObjectUtils;
071import org.jfree.chart.util.PaintUtils;
072import org.jfree.chart.util.Args;
073import org.jfree.chart.util.SerialUtils;
074import org.jfree.chart.util.ShapeUtils;
075import org.jfree.data.category.CategoryDataset;
076
077/**
078 * An axis that displays categories.
079 */
080public class CategoryAxis extends Axis implements Cloneable, Serializable {
081
082    /** For serialization. */
083    private static final long serialVersionUID = 5886554608114265863L;
084
085    /**
086     * The default margin for the axis (used for both lower and upper margins).
087     */
088    public static final double DEFAULT_AXIS_MARGIN = 0.05;
089
090    /**
091     * The default margin between categories (a percentage of the overall axis
092     * length).
093     */
094    public static final double DEFAULT_CATEGORY_MARGIN = 0.20;
095
096    /** The amount of space reserved at the start of the axis. */
097    private double lowerMargin;
098
099    /** The amount of space reserved at the end of the axis. */
100    private double upperMargin;
101
102    /** The amount of space reserved between categories. */
103    private double categoryMargin;
104
105    /** The maximum number of lines for category labels. */
106    private int maximumCategoryLabelLines;
107
108    /**
109     * A ratio that is multiplied by the width of one category to determine the
110     * maximum label width.
111     */
112    private float maximumCategoryLabelWidthRatio;
113
114    /** The category label offset. */
115    private int categoryLabelPositionOffset;
116
117    /**
118     * A structure defining the category label positions for each axis
119     * location.
120     */
121    private CategoryLabelPositions categoryLabelPositions;
122
123    /** Storage for tick label font overrides (if any). */
124    private Map tickLabelFontMap;
125
126    /** Storage for tick label paint overrides (if any). */
127    private transient Map tickLabelPaintMap;
128
129    /** Storage for the category label tooltips (if any). */
130    private Map categoryLabelToolTips;
131
132    /** Storage for the category label URLs (if any). */
133    private Map categoryLabelURLs;
134    
135    /**
136     * Creates a new category axis with no label.
137     */
138    public CategoryAxis() {
139        this(null);
140    }
141
142    /**
143     * Constructs a category axis, using default values where necessary.
144     *
145     * @param label  the axis label ({@code null} permitted).
146     */
147    public CategoryAxis(String label) {
148        super(label);
149
150        this.lowerMargin = DEFAULT_AXIS_MARGIN;
151        this.upperMargin = DEFAULT_AXIS_MARGIN;
152        this.categoryMargin = DEFAULT_CATEGORY_MARGIN;
153        this.maximumCategoryLabelLines = 1;
154        this.maximumCategoryLabelWidthRatio = 0.0f;
155
156        this.categoryLabelPositionOffset = 4;
157        this.categoryLabelPositions = CategoryLabelPositions.STANDARD;
158        this.tickLabelFontMap = new HashMap();
159        this.tickLabelPaintMap = new HashMap();
160        this.categoryLabelToolTips = new HashMap();
161        this.categoryLabelURLs = new HashMap();
162    }
163
164    /**
165     * Returns the lower margin for the axis.
166     *
167     * @return The margin.
168     *
169     * @see #getUpperMargin()
170     * @see #setLowerMargin(double)
171     */
172    public double getLowerMargin() {
173        return this.lowerMargin;
174    }
175
176    /**
177     * Sets the lower margin for the axis and sends an {@link AxisChangeEvent}
178     * to all registered listeners.
179     *
180     * @param margin  the margin as a percentage of the axis length (for
181     *                example, 0.05 is five percent).
182     *
183     * @see #getLowerMargin()
184     */
185    public void setLowerMargin(double margin) {
186        this.lowerMargin = margin;
187        fireChangeEvent();
188    }
189
190    /**
191     * Returns the upper margin for the axis.
192     *
193     * @return The margin.
194     *
195     * @see #getLowerMargin()
196     * @see #setUpperMargin(double)
197     */
198    public double getUpperMargin() {
199        return this.upperMargin;
200    }
201
202    /**
203     * Sets the upper margin for the axis and sends an {@link AxisChangeEvent}
204     * to all registered listeners.
205     *
206     * @param margin  the margin as a percentage of the axis length (for
207     *                example, 0.05 is five percent).
208     *
209     * @see #getUpperMargin()
210     */
211    public void setUpperMargin(double margin) {
212        this.upperMargin = margin;
213        fireChangeEvent();
214    }
215
216    /**
217     * Returns the category margin.
218     *
219     * @return The margin.
220     *
221     * @see #setCategoryMargin(double)
222     */
223    public double getCategoryMargin() {
224        return this.categoryMargin;
225    }
226
227    /**
228     * Sets the category margin and sends an {@link AxisChangeEvent} to all
229     * registered listeners.  The overall category margin is distributed over
230     * N-1 gaps, where N is the number of categories on the axis.
231     *
232     * @param margin  the margin as a percentage of the axis length (for
233     *                example, 0.05 is five percent).
234     *
235     * @see #getCategoryMargin()
236     */
237    public void setCategoryMargin(double margin) {
238        this.categoryMargin = margin;
239        fireChangeEvent();
240    }
241
242    /**
243     * Returns the maximum number of lines to use for each category label.
244     *
245     * @return The maximum number of lines.
246     *
247     * @see #setMaximumCategoryLabelLines(int)
248     */
249    public int getMaximumCategoryLabelLines() {
250        return this.maximumCategoryLabelLines;
251    }
252
253    /**
254     * Sets the maximum number of lines to use for each category label and
255     * sends an {@link AxisChangeEvent} to all registered listeners.
256     *
257     * @param lines  the maximum number of lines.
258     *
259     * @see #getMaximumCategoryLabelLines()
260     */
261    public void setMaximumCategoryLabelLines(int lines) {
262        this.maximumCategoryLabelLines = lines;
263        fireChangeEvent();
264    }
265
266    /**
267     * Returns the category label width ratio.
268     *
269     * @return The ratio.
270     *
271     * @see #setMaximumCategoryLabelWidthRatio(float)
272     */
273    public float getMaximumCategoryLabelWidthRatio() {
274        return this.maximumCategoryLabelWidthRatio;
275    }
276
277    /**
278     * Sets the maximum category label width ratio and sends an
279     * {@link AxisChangeEvent} to all registered listeners.
280     *
281     * @param ratio  the ratio.
282     *
283     * @see #getMaximumCategoryLabelWidthRatio()
284     */
285    public void setMaximumCategoryLabelWidthRatio(float ratio) {
286        this.maximumCategoryLabelWidthRatio = ratio;
287        fireChangeEvent();
288    }
289
290    /**
291     * Returns the offset between the axis and the category labels (before
292     * label positioning is taken into account).
293     *
294     * @return The offset (in Java2D units).
295     *
296     * @see #setCategoryLabelPositionOffset(int)
297     */
298    public int getCategoryLabelPositionOffset() {
299        return this.categoryLabelPositionOffset;
300    }
301
302    /**
303     * Sets the offset between the axis and the category labels (before label
304     * positioning is taken into account) and sends a change event to all 
305     * registered listeners.
306     *
307     * @param offset  the offset (in Java2D units).
308     *
309     * @see #getCategoryLabelPositionOffset()
310     */
311    public void setCategoryLabelPositionOffset(int offset) {
312        this.categoryLabelPositionOffset = offset;
313        fireChangeEvent();
314    }
315
316    /**
317     * Returns the category label position specification (this contains label
318     * positioning info for all four possible axis locations).
319     *
320     * @return The positions (never {@code null}).
321     *
322     * @see #setCategoryLabelPositions(CategoryLabelPositions)
323     */
324    public CategoryLabelPositions getCategoryLabelPositions() {
325        return this.categoryLabelPositions;
326    }
327
328    /**
329     * Sets the category label position specification for the axis and sends an
330     * {@link AxisChangeEvent} to all registered listeners.
331     *
332     * @param positions  the positions ({@code null} not permitted).
333     *
334     * @see #getCategoryLabelPositions()
335     */
336    public void setCategoryLabelPositions(CategoryLabelPositions positions) {
337        Args.nullNotPermitted(positions, "positions");
338        this.categoryLabelPositions = positions;
339        fireChangeEvent();
340    }
341
342    /**
343     * Returns the font for the tick label for the given category.
344     *
345     * @param category  the category ({@code null} not permitted).
346     *
347     * @return The font (never {@code null}).
348     *
349     * @see #setTickLabelFont(Comparable, Font)
350     */
351    public Font getTickLabelFont(Comparable category) {
352        Args.nullNotPermitted(category, "category");
353        Font result = (Font) this.tickLabelFontMap.get(category);
354        // if there is no specific font, use the general one...
355        if (result == null) {
356            result = getTickLabelFont();
357        }
358        return result;
359    }
360
361    /**
362     * Sets the font for the tick label for the specified category and sends
363     * an {@link AxisChangeEvent} to all registered listeners.
364     *
365     * @param category  the category ({@code null} not permitted).
366     * @param font  the font ({@code null} permitted).
367     *
368     * @see #getTickLabelFont(Comparable)
369     */
370    public void setTickLabelFont(Comparable category, Font font) {
371        Args.nullNotPermitted(category, "category");
372        if (font == null) {
373            this.tickLabelFontMap.remove(category);
374        }
375        else {
376            this.tickLabelFontMap.put(category, font);
377        }
378        fireChangeEvent();
379    }
380
381    /**
382     * Returns the paint for the tick label for the given category.
383     *
384     * @param category  the category ({@code null} not permitted).
385     *
386     * @return The paint (never {@code null}).
387     *
388     * @see #setTickLabelPaint(Paint)
389     */
390    public Paint getTickLabelPaint(Comparable category) {
391        Args.nullNotPermitted(category, "category");
392        Paint result = (Paint) this.tickLabelPaintMap.get(category);
393        // if there is no specific paint, use the general one...
394        if (result == null) {
395            result = getTickLabelPaint();
396        }
397        return result;
398    }
399
400    /**
401     * Sets the paint for the tick label for the specified category and sends
402     * an {@link AxisChangeEvent} to all registered listeners.
403     *
404     * @param category  the category ({@code null} not permitted).
405     * @param paint  the paint ({@code null} permitted).
406     *
407     * @see #getTickLabelPaint(Comparable)
408     */
409    public void setTickLabelPaint(Comparable category, Paint paint) {
410        Args.nullNotPermitted(category, "category");
411        if (paint == null) {
412            this.tickLabelPaintMap.remove(category);
413        }
414        else {
415            this.tickLabelPaintMap.put(category, paint);
416        }
417        fireChangeEvent();
418    }
419
420    /**
421     * Adds a tooltip to the specified category and sends an
422     * {@link AxisChangeEvent} to all registered listeners.
423     *
424     * @param category  the category ({@code null} not permitted).
425     * @param tooltip  the tooltip text ({@code null} permitted).
426     *
427     * @see #removeCategoryLabelToolTip(Comparable)
428     */
429    public void addCategoryLabelToolTip(Comparable category, String tooltip) {
430        Args.nullNotPermitted(category, "category");
431        this.categoryLabelToolTips.put(category, tooltip);
432        fireChangeEvent();
433    }
434
435    /**
436     * Returns the tool tip text for the label belonging to the specified
437     * category.
438     *
439     * @param category  the category ({@code null} not permitted).
440     *
441     * @return The tool tip text (possibly {@code null}).
442     *
443     * @see #addCategoryLabelToolTip(Comparable, String)
444     * @see #removeCategoryLabelToolTip(Comparable)
445     */
446    public String getCategoryLabelToolTip(Comparable category) {
447        Args.nullNotPermitted(category, "category");
448        return (String) this.categoryLabelToolTips.get(category);
449    }
450
451    /**
452     * Removes the tooltip for the specified category and, if there was a value
453     * associated with that category, sends an {@link AxisChangeEvent} to all 
454     * registered listeners.
455     *
456     * @param category  the category ({@code null} not permitted).
457     *
458     * @see #addCategoryLabelToolTip(Comparable, String)
459     * @see #clearCategoryLabelToolTips()
460     */
461    public void removeCategoryLabelToolTip(Comparable category) {
462        Args.nullNotPermitted(category, "category");
463        if (this.categoryLabelToolTips.remove(category) != null) {
464            fireChangeEvent();
465        }
466    }
467
468    /**
469     * Clears the category label tooltips and sends an {@link AxisChangeEvent}
470     * to all registered listeners.
471     *
472     * @see #addCategoryLabelToolTip(Comparable, String)
473     * @see #removeCategoryLabelToolTip(Comparable)
474     */
475    public void clearCategoryLabelToolTips() {
476        this.categoryLabelToolTips.clear();
477        fireChangeEvent();
478    }
479
480    /**
481     * Adds a URL (to be used in image maps) to the specified category and 
482     * sends an {@link AxisChangeEvent} to all registered listeners.
483     *
484     * @param category  the category ({@code null} not permitted).
485     * @param url  the URL text ({@code null} permitted).
486     *
487     * @see #removeCategoryLabelURL(Comparable)
488     * 
489     * @since 1.0.16
490     */
491    public void addCategoryLabelURL(Comparable category, String url) {
492        Args.nullNotPermitted(category, "category");
493        this.categoryLabelURLs.put(category, url);
494        fireChangeEvent();
495    }
496
497    /**
498     * Returns the URL for the label belonging to the specified category.
499     *
500     * @param category  the category ({@code null} not permitted).
501     *
502     * @return The URL text (possibly {@code null}).
503     * 
504     * @see #addCategoryLabelURL(Comparable, String)
505     * @see #removeCategoryLabelURL(Comparable)
506     * 
507     * @since 1.0.16
508     */
509    public String getCategoryLabelURL(Comparable category) {
510        Args.nullNotPermitted(category, "category");
511        return (String) this.categoryLabelURLs.get(category);
512    }
513
514    /**
515     * Removes the URL for the specified category and, if there was a URL 
516     * associated with that category, sends an {@link AxisChangeEvent} to all 
517     * registered listeners.
518     *
519     * @param category  the category ({@code null} not permitted).
520     *
521     * @see #addCategoryLabelURL(Comparable, String)
522     * @see #clearCategoryLabelURLs()
523     * 
524     * @since 1.0.16
525     */
526    public void removeCategoryLabelURL(Comparable category) {
527        Args.nullNotPermitted(category, "category");
528        if (this.categoryLabelURLs.remove(category) != null) {
529            fireChangeEvent();
530        }
531    }
532
533    /**
534     * Clears the category label URLs and sends an {@link AxisChangeEvent}
535     * to all registered listeners.
536     *
537     * @see #addCategoryLabelURL(Comparable, String)
538     * @see #removeCategoryLabelURL(Comparable)
539     * 
540     * @since 1.0.16
541     */
542    public void clearCategoryLabelURLs() {
543        this.categoryLabelURLs.clear();
544        fireChangeEvent();
545    }
546    
547    /**
548     * Returns the Java 2D coordinate for a category.
549     *
550     * @param anchor  the anchor point.
551     * @param category  the category index.
552     * @param categoryCount  the category count.
553     * @param area  the data area.
554     * @param edge  the location of the axis.
555     *
556     * @return The coordinate.
557     */
558    public double getCategoryJava2DCoordinate(CategoryAnchor anchor, 
559            int category, int categoryCount, Rectangle2D area, 
560            RectangleEdge edge) {
561
562        double result = 0.0;
563        if (anchor == CategoryAnchor.START) {
564            result = getCategoryStart(category, categoryCount, area, edge);
565        }
566        else if (anchor == CategoryAnchor.MIDDLE) {
567            result = getCategoryMiddle(category, categoryCount, area, edge);
568        }
569        else if (anchor == CategoryAnchor.END) {
570            result = getCategoryEnd(category, categoryCount, area, edge);
571        }
572        return result;
573
574    }
575
576    /**
577     * Returns the starting coordinate for the specified category.
578     *
579     * @param category  the category.
580     * @param categoryCount  the number of categories.
581     * @param area  the data area.
582     * @param edge  the axis location.
583     *
584     * @return The coordinate.
585     *
586     * @see #getCategoryMiddle(int, int, Rectangle2D, RectangleEdge)
587     * @see #getCategoryEnd(int, int, Rectangle2D, RectangleEdge)
588     */
589    public double getCategoryStart(int category, int categoryCount, 
590            Rectangle2D area, RectangleEdge edge) {
591
592        double result = 0.0;
593        if ((edge == RectangleEdge.TOP) || (edge == RectangleEdge.BOTTOM)) {
594            result = area.getX() + area.getWidth() * getLowerMargin();
595        }
596        else if ((edge == RectangleEdge.LEFT)
597                || (edge == RectangleEdge.RIGHT)) {
598            result = area.getMinY() + area.getHeight() * getLowerMargin();
599        }
600
601        double categorySize = calculateCategorySize(categoryCount, area, edge);
602        double categoryGapWidth = calculateCategoryGapSize(categoryCount, area,
603                edge);
604
605        result = result + category * (categorySize + categoryGapWidth);
606        return result;
607    }
608
609    /**
610     * Returns the middle coordinate for the specified category.
611     *
612     * @param category  the category.
613     * @param categoryCount  the number of categories.
614     * @param area  the data area.
615     * @param edge  the axis location.
616     *
617     * @return The coordinate.
618     *
619     * @see #getCategoryStart(int, int, Rectangle2D, RectangleEdge)
620     * @see #getCategoryEnd(int, int, Rectangle2D, RectangleEdge)
621     */
622    public double getCategoryMiddle(int category, int categoryCount,
623            Rectangle2D area, RectangleEdge edge) {
624
625        if (category < 0 || category >= categoryCount) {
626            throw new IllegalArgumentException("Invalid category index: "
627                    + category);
628        }
629        return getCategoryStart(category, categoryCount, area, edge)
630               + calculateCategorySize(categoryCount, area, edge) / 2;
631
632    }
633
634    /**
635     * Returns the end coordinate for the specified category.
636     *
637     * @param category  the category.
638     * @param categoryCount  the number of categories.
639     * @param area  the data area.
640     * @param edge  the axis location.
641     *
642     * @return The coordinate.
643     *
644     * @see #getCategoryStart(int, int, Rectangle2D, RectangleEdge)
645     * @see #getCategoryMiddle(int, int, Rectangle2D, RectangleEdge)
646     */
647    public double getCategoryEnd(int category, int categoryCount,
648            Rectangle2D area, RectangleEdge edge) {
649        return getCategoryStart(category, categoryCount, area, edge)
650               + calculateCategorySize(categoryCount, area, edge);
651    }
652
653    /**
654     * A convenience method that returns the axis coordinate for the centre of
655     * a category.
656     *
657     * @param category  the category key ({@code null} not permitted).
658     * @param categories  the categories ({@code null} not permitted).
659     * @param area  the data area ({@code null} not permitted).
660     * @param edge  the edge along which the axis lies ({@code null} not
661     *     permitted).
662     *
663     * @return The centre coordinate.
664     *
665     * @since 1.0.11
666     *
667     * @see #getCategorySeriesMiddle(Comparable, Comparable, CategoryDataset,
668     *     double, Rectangle2D, RectangleEdge)
669     */
670    public double getCategoryMiddle(Comparable category,
671            List categories, Rectangle2D area, RectangleEdge edge) {
672        Args.nullNotPermitted(categories, "categories");
673        int categoryIndex = categories.indexOf(category);
674        int categoryCount = categories.size();
675        return getCategoryMiddle(categoryIndex, categoryCount, area, edge);
676    }
677
678    /**
679     * Returns the middle coordinate (in Java2D space) for a series within a
680     * category.
681     *
682     * @param category  the category ({@code null} not permitted).
683     * @param seriesKey  the series key ({@code null} not permitted).
684     * @param dataset  the dataset ({@code null} not permitted).
685     * @param itemMargin  the item margin (0.0 &lt;= itemMargin &lt; 1.0);
686     * @param area  the area ({@code null} not permitted).
687     * @param edge  the edge ({@code null} not permitted).
688     *
689     * @return The coordinate in Java2D space.
690     *
691     * @since 1.0.7
692     */
693    public double getCategorySeriesMiddle(Comparable category,
694            Comparable seriesKey, CategoryDataset dataset, double itemMargin,
695            Rectangle2D area, RectangleEdge edge) {
696
697        int categoryIndex = dataset.getColumnIndex(category);
698        int categoryCount = dataset.getColumnCount();
699        int seriesIndex = dataset.getRowIndex(seriesKey);
700        int seriesCount = dataset.getRowCount();
701        double start = getCategoryStart(categoryIndex, categoryCount, area,
702                edge);
703        double end = getCategoryEnd(categoryIndex, categoryCount, area, edge);
704        double width = end - start;
705        if (seriesCount == 1) {
706            return start + width / 2.0;
707        }
708        else {
709            double gap = (width * itemMargin) / (seriesCount - 1);
710            double ww = (width * (1 - itemMargin)) / seriesCount;
711            return start + (seriesIndex * (ww + gap)) + ww / 2.0;
712        }
713    }
714
715    /**
716     * Returns the middle coordinate (in Java2D space) for a series within a
717     * category.
718     *
719     * @param categoryIndex  the category index.
720     * @param categoryCount  the category count.
721     * @param seriesIndex the series index.
722     * @param seriesCount the series count.
723     * @param itemMargin  the item margin (0.0 &lt;= itemMargin &lt; 1.0);
724     * @param area  the area ({@code null} not permitted).
725     * @param edge  the edge ({@code null} not permitted).
726     *
727     * @return The coordinate in Java2D space.
728     *
729     * @since 1.0.13
730     */
731    public double getCategorySeriesMiddle(int categoryIndex, int categoryCount,
732            int seriesIndex, int seriesCount, double itemMargin,
733            Rectangle2D area, RectangleEdge edge) {
734
735        double start = getCategoryStart(categoryIndex, categoryCount, area,
736                edge);
737        double end = getCategoryEnd(categoryIndex, categoryCount, area, edge);
738        double width = end - start;
739        if (seriesCount == 1) {
740            return start + width / 2.0;
741        }
742        else {
743            double gap = (width * itemMargin) / (seriesCount - 1);
744            double ww = (width * (1 - itemMargin)) / seriesCount;
745            return start + (seriesIndex * (ww + gap)) + ww / 2.0;
746        }
747    }
748
749    /**
750     * Calculates the size (width or height, depending on the location of the
751     * axis) of a category.
752     *
753     * @param categoryCount  the number of categories.
754     * @param area  the area within which the categories will be drawn.
755     * @param edge  the axis location.
756     *
757     * @return The category size.
758     */
759    protected double calculateCategorySize(int categoryCount, Rectangle2D area,
760            RectangleEdge edge) {
761        double result;
762        double available = 0.0;
763
764        if ((edge == RectangleEdge.TOP) || (edge == RectangleEdge.BOTTOM)) {
765            available = area.getWidth();
766        }
767        else if ((edge == RectangleEdge.LEFT)
768                || (edge == RectangleEdge.RIGHT)) {
769            available = area.getHeight();
770        }
771        if (categoryCount > 1) {
772            result = available * (1 - getLowerMargin() - getUpperMargin()
773                     - getCategoryMargin());
774            result = result / categoryCount;
775        }
776        else {
777            result = available * (1 - getLowerMargin() - getUpperMargin());
778        }
779        return result;
780    }
781
782    /**
783     * Calculates the size (width or height, depending on the location of the
784     * axis) of a category gap.
785     *
786     * @param categoryCount  the number of categories.
787     * @param area  the area within which the categories will be drawn.
788     * @param edge  the axis location.
789     *
790     * @return The category gap width.
791     */
792    protected double calculateCategoryGapSize(int categoryCount, 
793            Rectangle2D area, RectangleEdge edge) {
794
795        double result = 0.0;
796        double available = 0.0;
797
798        if ((edge == RectangleEdge.TOP) || (edge == RectangleEdge.BOTTOM)) {
799            available = area.getWidth();
800        }
801        else if ((edge == RectangleEdge.LEFT)
802                || (edge == RectangleEdge.RIGHT)) {
803            available = area.getHeight();
804        }
805
806        if (categoryCount > 1) {
807            result = available * getCategoryMargin() / (categoryCount - 1);
808        }
809        return result;
810    }
811
812    /**
813     * Estimates the space required for the axis, given a specific drawing area.
814     *
815     * @param g2  the graphics device (used to obtain font information).
816     * @param plot  the plot that the axis belongs to.
817     * @param plotArea  the area within which the axis should be drawn.
818     * @param edge  the axis location (top or bottom).
819     * @param space  the space already reserved.
820     *
821     * @return The space required to draw the axis.
822     */
823    @Override
824    public AxisSpace reserveSpace(Graphics2D g2, Plot plot, 
825            Rectangle2D plotArea, RectangleEdge edge, AxisSpace space) {
826
827        // create a new space object if one wasn't supplied...
828        if (space == null) {
829            space = new AxisSpace();
830        }
831
832        // if the axis is not visible, no additional space is required...
833        if (!isVisible()) {
834            return space;
835        }
836
837        // calculate the max size of the tick labels (if visible)...
838        double tickLabelHeight = 0.0;
839        double tickLabelWidth = 0.0;
840        if (isTickLabelsVisible()) {
841            g2.setFont(getTickLabelFont());
842            AxisState state = new AxisState();
843            // we call refresh ticks just to get the maximum width or height
844            refreshTicks(g2, state, plotArea, edge);
845            if (edge == RectangleEdge.TOP) {
846                tickLabelHeight = state.getMax();
847            }
848            else if (edge == RectangleEdge.BOTTOM) {
849                tickLabelHeight = state.getMax();
850            }
851            else if (edge == RectangleEdge.LEFT) {
852                tickLabelWidth = state.getMax();
853            }
854            else if (edge == RectangleEdge.RIGHT) {
855                tickLabelWidth = state.getMax();
856            }
857        }
858
859        // get the axis label size and update the space object...
860        Rectangle2D labelEnclosure = getLabelEnclosure(g2, edge);
861        double labelHeight, labelWidth;
862        if (RectangleEdge.isTopOrBottom(edge)) {
863            labelHeight = labelEnclosure.getHeight();
864            space.add(labelHeight + tickLabelHeight
865                    + this.categoryLabelPositionOffset, edge);
866        }
867        else if (RectangleEdge.isLeftOrRight(edge)) {
868            labelWidth = labelEnclosure.getWidth();
869            space.add(labelWidth + tickLabelWidth
870                    + this.categoryLabelPositionOffset, edge);
871        }
872        return space;
873    }
874
875    /**
876     * Configures the axis against the current plot.
877     */
878    @Override
879    public void configure() {
880        // nothing required
881    }
882
883    /**
884     * Draws the axis on a Java 2D graphics device (such as the screen or a
885     * printer).
886     *
887     * @param g2  the graphics device ({@code null} not permitted).
888     * @param cursor  the cursor location.
889     * @param plotArea  the area within which the axis should be drawn
890     *                  ({@code null} not permitted).
891     * @param dataArea  the area within which the plot is being drawn
892     *                  ({@code null} not permitted).
893     * @param edge  the location of the axis ({@code null} not permitted).
894     * @param plotState  collects information about the plot
895     *                   ({@code null} permitted).
896     *
897     * @return The axis state (never {@code null}).
898     */
899    @Override
900    public AxisState draw(Graphics2D g2, double cursor, Rectangle2D plotArea,
901            Rectangle2D dataArea, RectangleEdge edge,
902            PlotRenderingInfo plotState) {
903
904        // if the axis is not visible, don't draw it...
905        if (!isVisible()) {
906            return new AxisState(cursor);
907        }
908
909        if (isAxisLineVisible()) {
910            drawAxisLine(g2, cursor, dataArea, edge);
911        }
912        AxisState state = new AxisState(cursor);
913        if (isTickMarksVisible()) {
914            drawTickMarks(g2, cursor, dataArea, edge, state);
915        }
916
917        createAndAddEntity(cursor, state, dataArea, edge, plotState);
918
919        // draw the category labels and axis label
920        state = drawCategoryLabels(g2, plotArea, dataArea, edge, state,
921                plotState);
922        if (getAttributedLabel() != null) {
923            state = drawAttributedLabel(getAttributedLabel(), g2, plotArea, 
924                    dataArea, edge, state);
925            
926        } else {
927            state = drawLabel(getLabel(), g2, plotArea, dataArea, edge, state);
928        }
929        return state;
930
931    }
932
933    /**
934     * Draws the category labels and returns the updated axis state.
935     *
936     * @param g2  the graphics device ({@code null} not permitted).
937     * @param plotArea  the plot area ({@code null} not permitted).
938     * @param dataArea  the area inside the axes ({@code null} not
939     *                  permitted).
940     * @param edge  the axis location ({@code null} not permitted).
941     * @param state  the axis state ({@code null} not permitted).
942     * @param plotState  collects information about the plot ({@code null}
943     *                   permitted).
944     *
945     * @return The updated axis state (never {@code null}).
946     */
947    protected AxisState drawCategoryLabels(Graphics2D g2, Rectangle2D plotArea,
948            Rectangle2D dataArea, RectangleEdge edge, AxisState state,
949            PlotRenderingInfo plotState) {
950
951        Args.nullNotPermitted(state, "state");
952        if (!isTickLabelsVisible()) {
953            return state;
954        }
955 
956        List ticks = refreshTicks(g2, state, plotArea, edge);
957        state.setTicks(ticks);
958        int categoryIndex = 0;
959        Iterator iterator = ticks.iterator();
960        while (iterator.hasNext()) {
961            CategoryTick tick = (CategoryTick) iterator.next();
962            g2.setFont(getTickLabelFont(tick.getCategory()));
963            g2.setPaint(getTickLabelPaint(tick.getCategory()));
964
965            CategoryLabelPosition position
966                    = this.categoryLabelPositions.getLabelPosition(edge);
967            double x0 = 0.0;
968            double x1 = 0.0;
969            double y0 = 0.0;
970            double y1 = 0.0;
971            if (edge == RectangleEdge.TOP) {
972                x0 = getCategoryStart(categoryIndex, ticks.size(), dataArea, 
973                        edge);
974                x1 = getCategoryEnd(categoryIndex, ticks.size(), dataArea, 
975                        edge);
976                y1 = state.getCursor() - this.categoryLabelPositionOffset;
977                y0 = y1 - state.getMax();
978            }
979            else if (edge == RectangleEdge.BOTTOM) {
980                x0 = getCategoryStart(categoryIndex, ticks.size(), dataArea, 
981                        edge);
982                x1 = getCategoryEnd(categoryIndex, ticks.size(), dataArea, 
983                        edge);
984                y0 = state.getCursor() + this.categoryLabelPositionOffset;
985                y1 = y0 + state.getMax();
986            }
987            else if (edge == RectangleEdge.LEFT) {
988                y0 = getCategoryStart(categoryIndex, ticks.size(), dataArea, 
989                        edge);
990                y1 = getCategoryEnd(categoryIndex, ticks.size(), dataArea,
991                        edge);
992                x1 = state.getCursor() - this.categoryLabelPositionOffset;
993                x0 = x1 - state.getMax();
994            }
995            else if (edge == RectangleEdge.RIGHT) {
996                y0 = getCategoryStart(categoryIndex, ticks.size(), dataArea, 
997                        edge);
998                y1 = getCategoryEnd(categoryIndex, ticks.size(), dataArea,
999                        edge);
1000                x0 = state.getCursor() + this.categoryLabelPositionOffset;
1001                x1 = x0 - state.getMax();
1002            }
1003            Rectangle2D area = new Rectangle2D.Double(x0, y0, (x1 - x0),
1004                    (y1 - y0));
1005            Point2D anchorPoint = position.getCategoryAnchor().getAnchorPoint(area);
1006            TextBlock block = tick.getLabel();
1007            block.draw(g2, (float) anchorPoint.getX(),
1008                    (float) anchorPoint.getY(), position.getLabelAnchor(),
1009                    (float) anchorPoint.getX(), (float) anchorPoint.getY(),
1010                    position.getAngle());
1011            Shape bounds = block.calculateBounds(g2,
1012                    (float) anchorPoint.getX(), (float) anchorPoint.getY(),
1013                    position.getLabelAnchor(), (float) anchorPoint.getX(),
1014                    (float) anchorPoint.getY(), position.getAngle());
1015            if (plotState != null && plotState.getOwner() != null) {
1016                EntityCollection entities = plotState.getOwner()
1017                        .getEntityCollection();
1018                if (entities != null) {
1019                    String tooltip = getCategoryLabelToolTip(
1020                            tick.getCategory());
1021                    String url = getCategoryLabelURL(tick.getCategory());
1022                    entities.add(new CategoryLabelEntity(tick.getCategory(),
1023                            bounds, tooltip, url));
1024                }
1025            }
1026            categoryIndex++;
1027        }
1028
1029        if (edge.equals(RectangleEdge.TOP)) {
1030            double h = state.getMax() + this.categoryLabelPositionOffset;
1031            state.cursorUp(h);
1032        }
1033        else if (edge.equals(RectangleEdge.BOTTOM)) {
1034            double h = state.getMax() + this.categoryLabelPositionOffset;
1035            state.cursorDown(h);
1036        }
1037        else if (edge == RectangleEdge.LEFT) {
1038            double w = state.getMax() + this.categoryLabelPositionOffset;
1039            state.cursorLeft(w);
1040        }
1041        else if (edge == RectangleEdge.RIGHT) {
1042            double w = state.getMax() + this.categoryLabelPositionOffset;
1043            state.cursorRight(w);
1044        }
1045        return state;
1046    }
1047
1048    /**
1049     * Creates a temporary list of ticks that can be used when drawing the axis.
1050     *
1051     * @param g2  the graphics device (used to get font measurements).
1052     * @param state  the axis state.
1053     * @param dataArea  the area inside the axes.
1054     * @param edge  the location of the axis.
1055     *
1056     * @return A list of ticks.
1057     */
1058    @Override
1059    public List refreshTicks(Graphics2D g2, AxisState state, 
1060            Rectangle2D dataArea, RectangleEdge edge) {
1061
1062        List ticks = new java.util.ArrayList();
1063
1064        // sanity check for data area...
1065        if (dataArea.getHeight() <= 0.0 || dataArea.getWidth() < 0.0) {
1066            return ticks;
1067        }
1068
1069        CategoryPlot plot = (CategoryPlot) getPlot();
1070        List categories = plot.getCategoriesForAxis(this);
1071        double max = 0.0;
1072
1073        if (categories != null) {
1074            CategoryLabelPosition position
1075                    = this.categoryLabelPositions.getLabelPosition(edge);
1076            float r = this.maximumCategoryLabelWidthRatio;
1077            if (r <= 0.0) {
1078                r = position.getWidthRatio();
1079            }
1080
1081            float l;
1082            if (position.getWidthType() == CategoryLabelWidthType.CATEGORY) {
1083                l = (float) calculateCategorySize(categories.size(), dataArea,
1084                        edge);
1085            }
1086            else {
1087                if (RectangleEdge.isLeftOrRight(edge)) {
1088                    l = (float) dataArea.getWidth();
1089                }
1090                else {
1091                    l = (float) dataArea.getHeight();
1092                }
1093            }
1094            int categoryIndex = 0;
1095            Iterator iterator = categories.iterator();
1096            while (iterator.hasNext()) {
1097                Comparable category = (Comparable) iterator.next();
1098                g2.setFont(getTickLabelFont(category));
1099                TextBlock label = createLabel(category, l * r, edge, g2);
1100                if (edge == RectangleEdge.TOP || edge == RectangleEdge.BOTTOM) {
1101                    max = Math.max(max, calculateTextBlockHeight(label,
1102                            position, g2));
1103                }
1104                else if (edge == RectangleEdge.LEFT
1105                        || edge == RectangleEdge.RIGHT) {
1106                    max = Math.max(max, calculateTextBlockWidth(label,
1107                            position, g2));
1108                }
1109                Tick tick = new CategoryTick(category, label,
1110                        position.getLabelAnchor(),
1111                        position.getRotationAnchor(), position.getAngle());
1112                ticks.add(tick);
1113                categoryIndex = categoryIndex + 1;
1114            }
1115        }
1116        state.setMax(max);
1117        return ticks;
1118
1119    }
1120
1121    /**
1122     * Draws the tick marks.
1123     * 
1124     * @param g2  the graphics target.
1125     * @param cursor  the cursor position (an offset when drawing multiple axes)
1126     * @param dataArea  the area for plotting the data.
1127     * @param edge  the location of the axis.
1128     * @param state  the axis state.
1129     *
1130     * @since 1.0.13
1131     */
1132    public void drawTickMarks(Graphics2D g2, double cursor,
1133            Rectangle2D dataArea, RectangleEdge edge, AxisState state) {
1134
1135        Plot p = getPlot();
1136        if (p == null) {
1137            return;
1138        }
1139        CategoryPlot plot = (CategoryPlot) p;
1140        double il = getTickMarkInsideLength();
1141        double ol = getTickMarkOutsideLength();
1142        Line2D line = new Line2D.Double();
1143        List categories = plot.getCategoriesForAxis(this);
1144        g2.setPaint(getTickMarkPaint());
1145        g2.setStroke(getTickMarkStroke());
1146        Object saved = g2.getRenderingHint(RenderingHints.KEY_STROKE_CONTROL);
1147        g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, 
1148                RenderingHints.VALUE_STROKE_NORMALIZE);
1149        if (edge.equals(RectangleEdge.TOP)) {
1150            Iterator iterator = categories.iterator();
1151            while (iterator.hasNext()) {
1152                Comparable key = (Comparable) iterator.next();
1153                double x = getCategoryMiddle(key, categories, dataArea, edge);
1154                line.setLine(x, cursor, x, cursor + il);
1155                g2.draw(line);
1156                line.setLine(x, cursor, x, cursor - ol);
1157                g2.draw(line);
1158            }
1159            state.cursorUp(ol);
1160        } else if (edge.equals(RectangleEdge.BOTTOM)) {
1161            Iterator iterator = categories.iterator();
1162            while (iterator.hasNext()) {
1163                Comparable key = (Comparable) iterator.next();
1164                double x = getCategoryMiddle(key, categories, dataArea, edge);
1165                line.setLine(x, cursor, x, cursor - il);
1166                g2.draw(line);
1167                line.setLine(x, cursor, x, cursor + ol);
1168                g2.draw(line);
1169            }
1170            state.cursorDown(ol);
1171        } else if (edge.equals(RectangleEdge.LEFT)) {
1172            Iterator iterator = categories.iterator();
1173            while (iterator.hasNext()) {
1174                Comparable key = (Comparable) iterator.next();
1175                double y = getCategoryMiddle(key, categories, dataArea, edge);
1176                line.setLine(cursor, y, cursor + il, y);
1177                g2.draw(line);
1178                line.setLine(cursor, y, cursor - ol, y);
1179                g2.draw(line);
1180            }
1181            state.cursorLeft(ol);
1182        } else if (edge.equals(RectangleEdge.RIGHT)) {
1183            Iterator iterator = categories.iterator();
1184            while (iterator.hasNext()) {
1185                Comparable key = (Comparable) iterator.next();
1186                double y = getCategoryMiddle(key, categories, dataArea, edge);
1187                line.setLine(cursor, y, cursor - il, y);
1188                g2.draw(line);
1189                line.setLine(cursor, y, cursor + ol, y);
1190                g2.draw(line);
1191            }
1192            state.cursorRight(ol);
1193        }
1194        g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, saved);
1195    }
1196
1197    /**
1198     * Creates a label.
1199     *
1200     * @param category  the category.
1201     * @param width  the available width.
1202     * @param edge  the edge on which the axis appears.
1203     * @param g2  the graphics device.
1204     *
1205     * @return A label.
1206     */
1207    protected TextBlock createLabel(Comparable category, float width,
1208            RectangleEdge edge, Graphics2D g2) {
1209        TextBlock label = TextUtils.createTextBlock(category.toString(),
1210                getTickLabelFont(category), getTickLabelPaint(category), width,
1211                this.maximumCategoryLabelLines, new G2TextMeasurer(g2));
1212        return label;
1213    }
1214
1215    /**
1216     * A utility method for determining the width of a text block.
1217     *
1218     * @param block  the text block.
1219     * @param position  the position.
1220     * @param g2  the graphics device.
1221     *
1222     * @return The width.
1223     */
1224    protected double calculateTextBlockWidth(TextBlock block,
1225            CategoryLabelPosition position, Graphics2D g2) {
1226        RectangleInsets insets = getTickLabelInsets();
1227        Size2D size = block.calculateDimensions(g2);
1228        Rectangle2D box = new Rectangle2D.Double(0.0, 0.0, size.getWidth(),
1229                size.getHeight());
1230        Shape rotatedBox = ShapeUtils.rotateShape(box, position.getAngle(),
1231                0.0f, 0.0f);
1232        double w = rotatedBox.getBounds2D().getWidth() + insets.getLeft()
1233                + insets.getRight();
1234        return w;
1235    }
1236
1237    /**
1238     * A utility method for determining the height of a text block.
1239     *
1240     * @param block  the text block.
1241     * @param position  the label position.
1242     * @param g2  the graphics device.
1243     *
1244     * @return The height.
1245     */
1246    protected double calculateTextBlockHeight(TextBlock block,
1247            CategoryLabelPosition position, Graphics2D g2) {
1248        RectangleInsets insets = getTickLabelInsets();
1249        Size2D size = block.calculateDimensions(g2);
1250        Rectangle2D box = new Rectangle2D.Double(0.0, 0.0, size.getWidth(),
1251                size.getHeight());
1252        Shape rotatedBox = ShapeUtils.rotateShape(box, position.getAngle(),
1253                0.0f, 0.0f);
1254        double h = rotatedBox.getBounds2D().getHeight()
1255                   + insets.getTop() + insets.getBottom();
1256        return h;
1257    }
1258
1259    /**
1260     * Creates a clone of the axis.
1261     *
1262     * @return A clone.
1263     *
1264     * @throws CloneNotSupportedException if some component of the axis does
1265     *         not support cloning.
1266     */
1267    @Override
1268    public Object clone() throws CloneNotSupportedException {
1269        CategoryAxis clone = (CategoryAxis) super.clone();
1270        clone.tickLabelFontMap = new HashMap(this.tickLabelFontMap);
1271        clone.tickLabelPaintMap = new HashMap(this.tickLabelPaintMap);
1272        clone.categoryLabelToolTips = new HashMap(this.categoryLabelToolTips);
1273        clone.categoryLabelURLs = new HashMap(this.categoryLabelToolTips);
1274        return clone;
1275    }
1276
1277    /**
1278     * Tests this axis for equality with an arbitrary object.
1279     *
1280     * @param obj  the object ({@code null} permitted).
1281     *
1282     * @return A boolean.
1283     */
1284    @Override
1285    public boolean equals(Object obj) {
1286        if (obj == this) {
1287            return true;
1288        }
1289        if (!(obj instanceof CategoryAxis)) {
1290            return false;
1291        }
1292        if (!super.equals(obj)) {
1293            return false;
1294        }
1295        CategoryAxis that = (CategoryAxis) obj;
1296        if (that.lowerMargin != this.lowerMargin) {
1297            return false;
1298        }
1299        if (that.upperMargin != this.upperMargin) {
1300            return false;
1301        }
1302        if (that.categoryMargin != this.categoryMargin) {
1303            return false;
1304        }
1305        if (that.maximumCategoryLabelWidthRatio
1306                != this.maximumCategoryLabelWidthRatio) {
1307            return false;
1308        }
1309        if (that.categoryLabelPositionOffset
1310                != this.categoryLabelPositionOffset) {
1311            return false;
1312        }
1313        if (!ObjectUtils.equal(that.categoryLabelPositions,
1314                this.categoryLabelPositions)) {
1315            return false;
1316        }
1317        if (!ObjectUtils.equal(that.categoryLabelToolTips,
1318                this.categoryLabelToolTips)) {
1319            return false;
1320        }
1321        if (!ObjectUtils.equal(this.categoryLabelURLs, 
1322                that.categoryLabelURLs)) {
1323            return false;
1324        }
1325        if (!ObjectUtils.equal(this.tickLabelFontMap,
1326                that.tickLabelFontMap)) {
1327            return false;
1328        }
1329        if (!equalPaintMaps(this.tickLabelPaintMap, that.tickLabelPaintMap)) {
1330            return false;
1331        }
1332        return true;
1333    }
1334
1335    /**
1336     * Returns a hash code for this object.
1337     *
1338     * @return A hash code.
1339     */
1340    @Override
1341    public int hashCode() {
1342        return super.hashCode();
1343    }
1344
1345    /**
1346     * Provides serialization support.
1347     *
1348     * @param stream  the output stream.
1349     *
1350     * @throws IOException  if there is an I/O error.
1351     */
1352    private void writeObject(ObjectOutputStream stream) throws IOException {
1353        stream.defaultWriteObject();
1354        writePaintMap(this.tickLabelPaintMap, stream);
1355    }
1356
1357    /**
1358     * Provides serialization support.
1359     *
1360     * @param stream  the input stream.
1361     *
1362     * @throws IOException  if there is an I/O error.
1363     * @throws ClassNotFoundException  if there is a classpath problem.
1364     */
1365    private void readObject(ObjectInputStream stream)
1366        throws IOException, ClassNotFoundException {
1367        stream.defaultReadObject();
1368        this.tickLabelPaintMap = readPaintMap(stream);
1369    }
1370
1371    /**
1372     * Reads a {@code Map} of ({@code Comparable}, {@code Paint})
1373     * elements from a stream.
1374     *
1375     * @param in  the input stream.
1376     *
1377     * @return The map.
1378     *
1379     * @throws IOException
1380     * @throws ClassNotFoundException
1381     *
1382     * @see #writePaintMap(Map, ObjectOutputStream)
1383     */
1384    private Map readPaintMap(ObjectInputStream in)
1385            throws IOException, ClassNotFoundException {
1386        boolean isNull = in.readBoolean();
1387        if (isNull) {
1388            return null;
1389        }
1390        Map result = new HashMap();
1391        int count = in.readInt();
1392        for (int i = 0; i < count; i++) {
1393            Comparable category = (Comparable) in.readObject();
1394            Paint paint = SerialUtils.readPaint(in);
1395            result.put(category, paint);
1396        }
1397        return result;
1398    }
1399
1400    /**
1401     * Writes a map of ({@code Comparable}, {@code Paint})
1402     * elements to a stream.
1403     *
1404     * @param map  the map ({@code null} permitted).
1405     *
1406     * @param out
1407     * @throws IOException
1408     *
1409     * @see #readPaintMap(ObjectInputStream)
1410     */
1411    private void writePaintMap(Map map, ObjectOutputStream out)
1412            throws IOException {
1413        if (map == null) {
1414            out.writeBoolean(true);
1415        }
1416        else {
1417            out.writeBoolean(false);
1418            Set keys = map.keySet();
1419            int count = keys.size();
1420            out.writeInt(count);
1421            Iterator iterator = keys.iterator();
1422            while (iterator.hasNext()) {
1423                Comparable key = (Comparable) iterator.next();
1424                out.writeObject(key);
1425                SerialUtils.writePaint((Paint) map.get(key), out);
1426            }
1427        }
1428    }
1429
1430    /**
1431     * Tests two maps containing ({@code Comparable}, {@code Paint})
1432     * elements for equality.
1433     *
1434     * @param map1  the first map ({@code null} not permitted).
1435     * @param map2  the second map ({@code null} not permitted).
1436     *
1437     * @return A boolean.
1438     */
1439    private boolean equalPaintMaps(Map map1, Map map2) {
1440        if (map1.size() != map2.size()) {
1441            return false;
1442        }
1443        Set entries = map1.entrySet();
1444        Iterator iterator = entries.iterator();
1445        while (iterator.hasNext()) {
1446            Map.Entry entry = (Map.Entry) iterator.next();
1447            Paint p1 = (Paint) entry.getValue();
1448            Paint p2 = (Paint) map2.get(entry.getKey());
1449            if (!PaintUtils.equal(p1, p2)) {
1450                return false;
1451            }
1452        }
1453        return true;
1454    }
1455
1456}