001package com.hfg.math;
002
003import java.awt.*;
004import java.awt.geom.Point2D;
005import java.awt.geom.Rectangle2D;
006import java.math.BigDecimal;
007import java.util.ArrayList;
008import java.util.Collections;
009import java.util.HashMap;
010import java.util.List;
011import java.util.Map;
012
013import com.hfg.graphics.TextUtil;
014import com.hfg.graphics.units.GfxUnits;
015import com.hfg.html.attribute.HTMLColor;
016import com.hfg.svg.SVG;
017import com.hfg.svg.SvgGroup;
018import com.hfg.svg.SvgRect;
019import com.hfg.util.StringUtil;
020import com.hfg.util.collection.OrderedMap;
021
022//------------------------------------------------------------------------------
023/**
024 Generic histogram for bucketing data into ranges.
025 <p></p>
026 @author J. Alex Taylor, hairyfatguy.com
027 */
028//------------------------------------------------------------------------------
029// com.hfg Library
030//
031// This library is free software; you can redistribute it and/or
032// modify it under the terms of the GNU Lesser General Public
033// License as published by the Free Software Foundation; either
034// version 2.1 of the License, or (at your option) any later version.
035//
036// This library is distributed in the hope that it will be useful,
037// but WITHOUT ANY WARRANTY; without even the implied warranty of
038// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
039// Lesser General Public License for more details.
040//
041// You should have received a copy of the GNU Lesser General Public
042// License along with this library; if not, write to the Free Software
043// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
044//
045// J. Alex Taylor, President, Founder, CEO, COO, CFO, OOPS hairyfatguy.com
046// jataylor@hairyfatguy.com
047//------------------------------------------------------------------------------
048
049public class Histogram<S extends Number>
050{
051   private S mBinSize;
052   private SimpleSampleStats mStats = new SimpleSampleStats();
053   private Map<Float, Range<S>> mRangeLookupMap = new HashMap<>();
054   private Map<Range<S>, Counter> mCounterMap = new HashMap<>();
055
056   //###########################################################################
057   // CONSTRUCTORS
058   //###########################################################################
059
060   //--------------------------------------------------------------------------
061   public Histogram(S inBinSize)
062   {
063      mBinSize = inBinSize;
064   }
065
066   //###########################################################################
067   // PUBLIC METHODS
068   //###########################################################################
069
070   //--------------------------------------------------------------------------
071   public S getBinSize()
072   {
073      return mBinSize;
074   }
075
076   //--------------------------------------------------------------------------
077   public SimpleSampleStats getStats()
078   {
079      return mStats;
080   }
081
082   //--------------------------------------------------------------------------
083   public void addData(S[] inValues)
084   {
085      if (inValues != null)
086      {
087         for (S value : inValues)
088         {
089            addData(value);
090         }
091      }
092   }
093
094   //--------------------------------------------------------------------------
095   public void addData(S inValue)
096   {
097      mStats.add(inValue);
098
099      // First, determine the range for the value
100
101      // Round the value down to the nearest range starting point
102      float binStart = (float) Math.floor(inValue.floatValue()/mBinSize.floatValue()) * mBinSize.floatValue();
103
104      Range<S> range = mRangeLookupMap.get(binStart);
105      if (null == range)
106      {
107         range = new Range<>();
108         range.setStart(fromFloat(binStart));
109         range.setEnd(fromFloat(binStart  + (mBinSize instanceof Integer ? -1 : 0) + mBinSize.floatValue()));
110
111         mRangeLookupMap.put(binStart, range);
112      }
113
114      Counter counter = mCounterMap.get(range);
115      if (null == counter)
116      {
117         counter = new Counter();
118         mCounterMap.put(range, counter);
119      }
120
121      counter.increment();
122   }
123
124   //--------------------------------------------------------------------------
125   public void addData(Map<S, Integer> inValueCountMap)
126   {
127      mStats.addAll((Map<Number, Integer>) inValueCountMap);
128
129      for (S value : inValueCountMap.keySet())
130      {
131         Integer count = inValueCountMap.get(value);
132         if (count != null
133             && count != 0)
134         {
135            // First, determine the range for the value
136
137            // Round the value down to the nearest range starting point
138            float binStart = (float) Math.floor(value.floatValue() / mBinSize.floatValue()) * mBinSize.floatValue();
139
140            Range<S> range = mRangeLookupMap.get(binStart);
141            if (null == range)
142            {
143               range = new Range<>();
144               range.setStart(fromFloat(binStart));
145               range.setEnd(fromFloat(binStart + (mBinSize instanceof Integer ? -1 : 0) + mBinSize.floatValue()));
146
147               mRangeLookupMap.put(binStart, range);
148            }
149
150
151            Counter counter = mCounterMap.get(range);
152            if (null == counter)
153            {
154               counter = new Counter();
155               mCounterMap.put(range, counter);
156            }
157
158            counter.add(count);
159         }
160      }
161   }
162
163   //--------------------------------------------------------------------------
164   public Map<Range<S>, Counter> getOrderedRangeMap()
165   {
166      // Start with the existing ranges.
167      List<Range<S>> orderedRanges = getOrderedRanges();
168
169      // Flesh out the list with missing ranges
170      for (int i = 1; i < orderedRanges.size(); i++)
171      {
172         S expectedStart = fromFloat(orderedRanges.get(i - 1).getStart().floatValue() + getBinSize().floatValue());
173         if (orderedRanges.get(i).getStart().floatValue() - expectedStart.floatValue() >= 0.9f * getBinSize().floatValue())
174         {
175            Range<S> newRange = new Range<>();
176            newRange.setStart(expectedStart);
177            newRange.setEnd(fromFloat(expectedStart.floatValue() + (mBinSize instanceof Integer ? -1 : 0) + getBinSize().floatValue()));
178
179            orderedRanges.add(i--, newRange);
180         }
181      }
182
183      Map<Range<S>, Counter> orderedMap = new OrderedMap<>(orderedRanges.size());
184      for (Range<S> range : orderedRanges)
185      {
186         orderedMap.put(range, mCounterMap.get(range));
187      }
188
189      return orderedMap;
190   }
191
192   //--------------------------------------------------------------------------
193   public Range<S> getOverallRange()
194   {
195      List<Range<S>> orderedRanges = getOrderedRanges();
196
197      Range<S> overallRange = new Range<>();
198      overallRange.setStart(orderedRanges.get(0).getStart());
199      overallRange.setEnd(orderedRanges.get(orderedRanges.size() - 1).getEnd());
200
201      return overallRange;
202   }
203
204   //--------------------------------------------------------------------------
205   public int getMaxCount()
206   {
207      int maxCount = 0;
208      for (Counter counter : mCounterMap.values())
209      {
210         if (counter.intValue() > maxCount)
211         {
212            maxCount = counter.intValue();
213         }
214      }
215
216      return maxCount;
217   }
218
219   //--------------------------------------------------------------------------
220   public SVG toSVG(HistogramSvgSettings inSettings)
221   {
222      int tickHeight = 6;
223      int labelOffset = 3;
224      String rangeLabelFormatString = inSettings.getRangeLabelFormatString();
225      if (null == rangeLabelFormatString)
226      {
227         rangeLabelFormatString = "%f";
228      }
229
230      SVG svg = new SVG();
231      svg.setFont(inSettings.getFont());
232
233      Range<S> overallRange = getOverallRange();
234
235      Map<Range<S>, Counter> orderedRangeMap = getOrderedRangeMap();
236
237      boolean singleIntBin = (getBinSize() instanceof Integer && getBinSize().floatValue() == 1);
238
239      float xScalingFactor = inSettings.calculateXScalingFactor(singleIntBin ? new Range<>(0, orderedRangeMap.size() - 1) : overallRange);
240      float yScalingFactor = inSettings.calculateYScalingFactor(new Range<>(0, getMaxCount()));
241
242      int xStartPx = (int) inSettings.calculateXStart().to(GfxUnits.pixels);
243      int xEndPx = (int) inSettings.calculateXEnd().to(GfxUnits.pixels);
244
245      int yTopPx = (int) inSettings.calculateYTop().to(GfxUnits.pixels);
246      int yBottomPx = (int) inSettings.calculateYBottom().to(GfxUnits.pixels);
247
248
249      // Generate the axes
250      SvgGroup axes = svg.addGroup().setId("Axes").addStyle("stroke:#000000");
251      // X-axis
252      SvgGroup xAxis = axes.addGroup().setId("X-axis");
253      xAxis.addLine(new Point(xStartPx, yBottomPx), new Point(xEndPx, yBottomPx));
254      int count = 0;
255      for (Map.Entry<Range<S>, Counter> bin : orderedRangeMap.entrySet())
256      {
257         count++;
258
259         int x;
260         if (singleIntBin)
261         {
262            // Special case where the bins are single integers
263            x = (int) (xStartPx + (count - 0.5) * xScalingFactor);
264         }
265         else
266         {
267            x = (int) (xStartPx + ((bin.getKey().getStart().floatValue() - overallRange.getStart().floatValue()) * xScalingFactor));
268         }
269
270         xAxis.addLine(new Point(x, yBottomPx), new Point(x, yBottomPx + tickHeight));
271
272         String label = String.format(rangeLabelFormatString, bin.getKey().getStart().floatValue());
273         Rectangle2D textBoundBox = TextUtil.getStringBaselineRect(label, inSettings.getFont());
274
275         float labelX = x - (int) textBoundBox.getWidth() / 2;
276         float labelY = (float) (yBottomPx + tickHeight + labelOffset + textBoundBox.getHeight() / 2 + textBoundBox.getWidth() / 2);
277         Point2D labelLoc = new Point2D.Float(labelX, labelY);
278         Point2D rotationCenter = new Point2D.Float(x, (float) (labelY - textBoundBox.getHeight() / 2));
279         xAxis.addText(label, inSettings.getFont(), labelLoc)
280               .setTransform("rotate(-90 " + rotationCenter.getX() + "," + rotationCenter.getY() + ")");
281
282         // Last range?
283         if (count == orderedRangeMap.size()
284             && ! singleIntBin)
285         {
286            float xEnd = bin.getKey().getEnd().floatValue() + (mBinSize instanceof Integer ? 1 : 0);
287
288            x = (int) (xStartPx + ((xEnd - overallRange.getStart().floatValue()) * xScalingFactor));
289
290            xAxis.addLine(new Point(x, yBottomPx), new Point(x, yBottomPx + tickHeight));
291
292            label = String.format(rangeLabelFormatString, xEnd);
293            textBoundBox = TextUtil.getStringBaselineRect(label, inSettings.getFont());
294
295            // Put the label down far enough so that when we center rotate it 90deg, it will line up with the tick mark
296            labelX = x - (float) textBoundBox.getWidth() / 2;
297            labelY = (float) (yBottomPx + tickHeight + labelOffset + textBoundBox.getHeight() / 2 + textBoundBox.getWidth() / 2);
298            labelLoc = new Point2D.Float(labelX, labelY);
299            rotationCenter = new Point2D.Float((float) x, (float) (labelY - textBoundBox.getHeight() / 2));
300            xAxis.addText(label, inSettings.getFont(), labelLoc)
301                  .setTransform("rotate(-90 " + rotationCenter.getX() + "," + rotationCenter.getY() + ")");
302         }
303      }
304
305
306
307      // Y-axis
308//      axes.addLine(new Point(xStartPx, yTopPx), new Point(xStartPx, yBottomPx));
309
310      // Add the bars
311      SvgGroup bars = svg.addGroup();
312
313      SvgGroup labels = svg.addGroup();
314
315      orderedRangeMap.entrySet().iterator().next();
316      int barWidth = (int) (getBinSize().floatValue() * xScalingFactor * 0.85f);
317
318      String barClass = inSettings.getBarStyleClass();
319
320      for (Map.Entry<Range<S>, Counter> bin : orderedRangeMap.entrySet())
321      {
322         int x = xStartPx + (int) ((bin.getKey().getStart().floatValue() - overallRange.getStart().floatValue()) * xScalingFactor + 0.075 * barWidth);
323         int height = bin.getValue() != null ? (int) (bin.getValue().intValue() * yScalingFactor) : 0;
324         int y = (int) (yBottomPx - height);
325
326         String label = (bin.getValue() != null ? bin.getValue().intValue() : 0) + "";
327
328         String rangeString;
329         if (singleIntBin)
330         {
331            rangeString = String.format(inSettings.getRangeLabelFormatString(),
332                                        bin.getKey().getStart().floatValue());
333         }
334         else
335         {
336            rangeString = String.format(inSettings.getRangeLabelFormatString() + ", " + inSettings.getRangeLabelFormatString(),
337                                        bin.getKey().getStart().floatValue(),
338                                        bin.getKey().getEnd().floatValue());
339         }
340
341         SvgRect bar = bars.addRect(new Rectangle(x, y, barWidth, height));
342         bar.setAttribute("data-range", rangeString);
343         bar.setTitle("[" + rangeString + "]: " + label);
344
345         if (StringUtil.isSet(barClass))
346         {
347            bar.addClass(barClass);
348         }
349         else
350         {
351            bar.addStyle("fill:#0000ff;")
352               .setStroke(HTMLColor.BLACK);
353         }
354
355         // Put the count (label) over the bar
356         Rectangle2D textBoundBox = TextUtil.getStringBaselineRect(label, inSettings.getFont());
357
358         Point2D labelLoc = new Point(x + (int) (barWidth - textBoundBox.getWidth())/2, (int) (y  - textBoundBox.getWidth()/2));
359         Point2D rotationCenter = new Point((int) (labelLoc.getX() + textBoundBox.getWidth()/2),
360                                            (int) (labelLoc.getY() - textBoundBox.getHeight()/2));
361
362         labels.addText(label, inSettings.getFont(), labelLoc)
363               .setTransform("rotate(-90 " + rotationCenter.getX() + "," + rotationCenter.getY() + ")");
364      }
365
366      svg.setHeight((int) inSettings.getHeight().to(GfxUnits.pixels));
367      svg.setWidth((int) inSettings.getWidth().to(GfxUnits.pixels));
368
369      return svg;
370   }
371
372   //###########################################################################
373   // PRIVATE METHODS
374   //###########################################################################
375
376   //--------------------------------------------------------------------------
377   public List<Range<S>> getOrderedRanges()
378   {
379      // Start with the existing ranges.
380      List<Range<S>> orderedRanges = new ArrayList<>(mCounterMap.keySet());
381      Collections.sort(orderedRanges);
382
383      return orderedRanges;
384   }
385
386   //--------------------------------------------------------------------------
387   private S fromFloat(Float inValue)
388   {
389      S value = null;
390      S binSize = getBinSize();
391
392      if (binSize instanceof Integer)
393      {
394         value = (S) new Integer(inValue.intValue());
395      }
396      else if (binSize instanceof Float)
397      {
398         value = (S) inValue;
399      }
400      else if (binSize instanceof Double)
401      {
402         value = (S) new Double(inValue.doubleValue());
403      }
404      else if (binSize instanceof Long)
405      {
406         value = (S) new Long(inValue.longValue());
407      }
408      else if (binSize instanceof BigDecimal)
409      {
410         value = (S) new BigDecimal(inValue.doubleValue());
411      }
412
413      return value;
414   }
415
416}