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}