View Javadoc
1   package org.opentrafficsim.swing.gui;
2   
3   import java.awt.FontMetrics;
4   import java.awt.Graphics;
5   import java.awt.Graphics2D;
6   import java.awt.RenderingHints;
7   import java.awt.geom.Rectangle2D;
8   import java.util.ArrayList;
9   import java.util.Hashtable;
10  import java.util.LinkedHashMap;
11  import java.util.LinkedHashSet;
12  import java.util.List;
13  import java.util.Objects;
14  import java.util.function.BiFunction;
15  import java.util.stream.IntStream;
16  
17  import javax.swing.JLabel;
18  import javax.swing.event.ChangeEvent;
19  import javax.swing.event.ChangeListener;
20  
21  import org.djutils.exceptions.Throw;
22  import org.djutils.swing.multislider.LinearMultiSlider;
23  
24  /**
25   * Editor for a distribution of probabilities of all possible categories. The probabilities must sum to 1.0.
26   * <p>
27   * Copyright (c) 2024-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
28   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
29   * </p>
30   * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
31   * @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
32   * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
33   * @param <T> category type
34   */
35  public class ProbabilityDistributionEditor<T> extends LinearMultiSlider<Double>
36  {
37  
38      /** */
39      private static final long serialVersionUID = 20250916L;
40  
41      /** Number of values the slider allows per percent. */
42      private final int valuesPerPercent;
43  
44      /** Categories. */
45      private final List<T> categories;
46  
47      /** Label function. */
48      private BiFunction<T, Double, String> labelFunction = (t, p) -> String.format("%s: %.1f%%", t, p * 100.0);
49  
50      /** Category font size. */
51      private float categoryFontSize = 10.0f;
52  
53      /**
54       * Constructor.
55       * @param categories categories
56       * @param probabilities probabilities
57       * @param valuesPerPercent number of values the slider allows per percent
58       */
59      public ProbabilityDistributionEditor(final List<T> categories, final double[] probabilities, final int valuesPerPercent)
60      {
61          super(0.0, 1.0, 100 * valuesPerPercent + 1, checkValues(probabilities));
62          Throw.whenNull(categories, "categories");
63          Throw.whenNull(probabilities, "probabilities");
64          Throw.when(categories.size() != new LinkedHashSet<>(categories).size(), IllegalArgumentException.class,
65                  "The categories are not unique.");
66          Throw.when(valuesPerPercent < 1, IllegalArgumentException.class, "valuesPerPercent should be at least 1.");
67          this.categories = new ArrayList<>(categories);
68          this.valuesPerPercent = 100 * valuesPerPercent;
69          // create default %-labels, although not shown by default
70          setLabelTable(new Hashtable<Integer, JLabel>(IntStream.range(0, 11).collect(() -> new LinkedHashMap<>(),
71                  (m, i) -> m.put(i * 10 * valuesPerPercent, new JLabel((i * 10) + "%")), (m1, m2) -> m1.putAll(m2))));
72          setMajorTickSpacing(5 * valuesPerPercent);
73          setPaintTicks(true);
74          setOverlap(true);
75          setPaintTrack(false);
76          this.addChangeListener(new ChangeListener()
77          {
78              @Override
79              public void stateChanged(final ChangeEvent e)
80              {
81                  // need to update the labels as we drag, otherwise the thumbs erase (part of) the labels
82                  repaint();
83              }
84          });
85      }
86  
87      /**
88       * Check values are positive and add up to one.
89       * @param probabilities probabilities
90       * @return cumulative result of probabilities
91       */
92      private static Double[] checkValues(final double[] probabilities)
93      {
94          Double[] out = new Double[probabilities.length - 1];
95          double cumul = 0.0;
96          for (int i = 0; i < probabilities.length - 1; i++)
97          {
98              Throw.when(probabilities[i] < 0.0, IllegalArgumentException.class, "Probabilities should not be negative.");
99              cumul += probabilities[i];
100             out[i] = cumul;
101         }
102         Throw.when(Math.abs(cumul + probabilities[probabilities.length - 1] - 1.0) > 1e-9, IllegalArgumentException.class,
103                 "Probabilities do not add up to one.");
104         return out;
105     }
106 
107     @Override
108     protected Double mapIndexToValue(final int index)
109     {
110         return ((double) index) / this.valuesPerPercent;
111     }
112 
113     /**
114      * Sets the label function. This function receives the category object and the probability in the normalized [0...1] range.
115      * @param labelfunction label function receiving the category object and the probability in the normalized [0...1] range
116      */
117     public void setCategoryLabelFunction(final BiFunction<T, Double, String> labelfunction)
118     {
119         Throw.whenNull(labelfunction, "labelfunction");
120         this.labelFunction = labelfunction;
121     }
122 
123     /**
124      * Set the font size for the category labels.
125      * @param categoryFontSize font size for the category labels
126      */
127     public void setCategoryFontSize(final float categoryFontSize)
128     {
129         Throw.when(categoryFontSize <= 0.0, IllegalArgumentException.class, "Category font size should be larger than 0.");
130         this.categoryFontSize = categoryFontSize;
131     }
132 
133     /** {@inheritDoc} */
134     @Override
135     public void paint(final Graphics g)
136     {
137         super.paint(g);
138         Graphics2D g2 = (Graphics2D) g;
139 
140         // edges between probability bands
141         int[] edges = new int[getNumberOfThumbs() + 2];
142         edges[0] = getTrackSizeLoPx();
143         for (int i = 0; i < getNumberOfThumbs(); i++)
144         {
145             edges[i + 1] = thumbPositionPx(i);
146         }
147         edges[edges.length - 1] = getTrackSizeLoPx() + trackSize();
148 
149         // draw labels
150         g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
151         g2.setFont(g2.getFont().deriveFont(this.categoryFontSize));
152         FontMetrics fontMetric = g2.getFontMetrics();
153         for (int i = 0; i < edges.length - 1; i++)
154         {
155             int x = (edges[i] + edges[i + 1]) / 2;
156             String categoryLabel;
157             try
158             {
159                 categoryLabel = this.labelFunction.apply(this.categories.get(i), getProbability(i));
160             }
161             catch (Exception e)
162             {
163                 categoryLabel = this.categories.get(i).toString();
164             }
165             Rectangle2D d = fontMetric.getStringBounds(categoryLabel, g2);
166             int left = (int) (x - d.getWidth() / 2.0);
167             if (left < 0)
168             {
169                 g2.drawString(categoryLabel, 0, fontMetric.getHeight());
170             }
171             else if (left + d.getWidth() > getWidth())
172             {
173                 g2.drawString(categoryLabel, (int) (getWidth() - d.getWidth()), fontMetric.getHeight());
174             }
175             else
176             {
177                 g2.drawString(categoryLabel, left, fontMetric.getHeight());
178             }
179         }
180     }
181 
182     /**
183      * Retrieve the current probability values.
184      * @return the probability values
185      */
186     public double[] getProbabilities()
187     {
188         double[] result = new double[this.categories.size()];
189         for (int i = 0; i < this.categories.size(); i++)
190         {
191             result[i] = getProbability(i);
192         }
193         return result;
194     }
195 
196     /**
197      * Returns the probability of the given category.
198      * @param t category
199      * @return the probability of the given category
200      * @throws IllegalArgumentException if the category object is not part of the distribution
201      */
202     public double getProbability(final T t)
203     {
204         Throw.when(!this.categories.contains(t), IllegalArgumentException.class, "Category {} is not part of the distribution.",
205                 t);
206         return getProbability(this.categories.indexOf(t));
207     }
208 
209     /**
210      * Returns the probability of category with given index.
211      * @param i category index
212      * @return the probability of category with given index
213      * @throws IndexOutOfBoundsException if the index is out of bounds
214      */
215     public double getProbability(final int i)
216     {
217         Objects.checkIndex(i, this.categories.size());
218         if (i == 0)
219         {
220             return getValue(0);
221         }
222         else if (i == this.categories.size() - 1)
223         {
224             return 1.0 - getValue(this.categories.size() - 2);
225         }
226         return getValue(i) - getValue(i - 1);
227     }
228 
229 }