├── lib ├── mini_histogram │ ├── version.rb │ └── plot.rb └── mini_histogram.rb ├── .gitignore ├── bin ├── setup └── console ├── Gemfile ├── test ├── test_helper.rb ├── fixtures │ ├── histogram_first.txt │ ├── side_by_side_histogram.txt │ └── dual_plot_with_titles.txt └── mini_histogram_test.rb ├── .github └── workflows │ ├── check_changelog.yml │ └── ci.yml ├── Rakefile ├── CHANGELOG.md ├── LICENSE.txt ├── mini_histogram.gemspec ├── CODE_OF_CONDUCT.md └── README.md /lib/mini_histogram/version.rb: -------------------------------------------------------------------------------- 1 | class MiniHistogram 2 | VERSION = "0.3.1" 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | Gemfile.lock 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in mini_histogram.gemspec 4 | gemspec 5 | 6 | gem "rake", "~> 12.0" 7 | gem "minitest", "~> 5.0" 8 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | MINI_HISTOGRAM_LIB_DIR = File.expand_path("../lib", __dir__) 2 | $LOAD_PATH.unshift MINI_HISTOGRAM_LIB_DIR 3 | require "mini_histogram" 4 | 5 | require "minitest/autorun" 6 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "mini_histogram" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /.github/workflows/check_changelog.yml: -------------------------------------------------------------------------------- 1 | name: Check Changelog 2 | on: [pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - name: Check that CHANGELOG is touched 9 | run: | 10 | cat $GITHUB_EVENT_PATH | jq .pull_request.title | grep -i '\[\(\(changelog skip\)\|\(ci skip\)\)\]' || git diff remotes/origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: 14 | - 2.3 15 | - 2.4 16 | - 2.5 17 | - 2.6 18 | - 2.7 19 | - '3.0' 20 | - 3.1 21 | - head 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v3 25 | - name: Set up Ruby 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: ${{ matrix.ruby }} 29 | bundler-cache: true 30 | - name: test 31 | run: bundle exec rake test 32 | continue-on-error: ${{ matrix.ruby == 'head' }} 33 | -------------------------------------------------------------------------------- /test/fixtures/histogram_first.txt: -------------------------------------------------------------------------------- 1 | ┌ ┐ 2 | [11.2 , 11.25) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 8 3 | [11.25, 11.3 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 14 4 | [11.3 , 11.35) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 12 5 | [11.35, 11.4 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 21 6 | [11.4 , 11.45) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 16 7 | [11.45, 11.5 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 10 8 | [11.5 , 11.55) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 9 9 | [11.55, 11.6 ) ┤▇▇▇▇▇▇▇▇▇▇ 6 10 | [11.6 , 11.65) ┤▇▇▇▇▇▇▇ 4 11 | └ ┘ 12 | Frequency -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | $LOAD_PATH.unshift File.expand_path("./lib", __dir__) 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/*_test.rb"] 10 | end 11 | 12 | task :default => :test 13 | 14 | 15 | task :bench do 16 | require 'benchmark/ips' 17 | require 'enumerable/statistics' 18 | require 'mini_histogram' 19 | 20 | array = 1000.times.map { rand } 21 | 22 | histogram = MiniHistogram.new(array) 23 | my_weights = histogram.weights 24 | puts array.histogram.weights == my_weights 25 | puts array.histogram.weights.inspect 26 | puts my_weights.inspect 27 | 28 | 29 | Benchmark.ips do |x| 30 | x.report("enumerable stats") { array.histogram } 31 | x.report("mini histogram ") { 32 | MiniHistogram.new(array).weights 33 | } 34 | x.compare! 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## HEAD 2 | 3 | ## 0.3.1 4 | 5 | - Add missing require for stringio (https://github.com/zombocom/mini_histogram/pull/7) 6 | 7 | ## 0.3.0 8 | 9 | - Generate dualing side-by-side histograms (https://github.com/zombocom/mini_histogram/pull/6) 10 | 11 | ## 0.2.2 12 | 13 | - Frozen string optimization in histogram/plot.rb (https://github.com/zombocom/mini_histogram/pull/5) 14 | 15 | ## 0.2.1 16 | 17 | - Added missing constant needed for plotting support (https://github.com/zombocom/mini_histogram/pull/4) 18 | 19 | ## 0.2.0 20 | 21 | - Experimental plotting support added (https://github.com/zombocom/mini_histogram/pull/3) 22 | 23 | ## 0.1.3 24 | 25 | - Handle edge cases (https://github.com/zombocom/mini_histogram/pull/2) 26 | 27 | ## 0.1.2 28 | 29 | - Add `edge` as alias to `edges` 30 | 31 | ## 0.1.1 32 | 33 | - Fix multi histogram weights, with set_average_edges! method (https://github.com/zombocom/mini_histogram/pull/1) 34 | 35 | ## 0.1.0 36 | 37 | - First 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 schneems 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixtures/side_by_side_histogram.txt: -------------------------------------------------------------------------------- 1 | ┌ ┐ ┌ ┐ 2 | [11.2 , 11.25) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 8 [11.2 , 11.25) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 8 3 | [11.25, 11.3 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 14 [11.25, 11.3 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 14 4 | [11.3 , 11.35) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 12 [11.3 , 11.35) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 12 5 | [11.35, 11.4 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 21 [11.35, 11.4 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 21 6 | [11.4 , 11.45) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 16 [11.4 , 11.45) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 16 7 | [11.45, 11.5 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 10 [11.45, 11.5 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 10 8 | [11.5 , 11.55) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 9 [11.5 , 11.55) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 9 9 | [11.55, 11.6 ) ┤▇▇▇▇▇▇▇▇▇▇ 6 [11.55, 11.6 ) ┤▇▇▇▇▇▇▇▇▇▇ 6 10 | [11.6 , 11.65) ┤▇▇▇▇▇▇▇ 4 [11.6 , 11.65) ┤▇▇▇▇▇▇▇ 4 11 | └ ┘ └ ┘ 12 | Frequency Frequency -------------------------------------------------------------------------------- /mini_histogram.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/mini_histogram/version' 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "mini_histogram" 5 | spec.version = MiniHistogram::VERSION 6 | spec.authors = ["schneems"] 7 | spec.email = ["richard.schneeman+foo@gmail.com"] 8 | 9 | spec.summary = %q{A small gem for building histograms out of Ruby arrays} 10 | spec.description = %q{It makes histograms out of Ruby data. How cool is that!? Pretty cool if you ask me.} 11 | spec.homepage = "https://github.com/zombocom/mini_histogram" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = Gem::Requirement.new(">= 2.1.0") 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | # spec.metadata["source_code_uri"] = "blerg" 17 | # spec.metadata["changelog_uri"] = "blerg" 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 23 | end 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | 28 | spec.add_development_dependency "m" 29 | # Used for comparison testing, but only supports Ruby 2.4+ 30 | # spec.add_development_dependency "enumerable-statistics" 31 | spec.add_development_dependency "benchmark-ips" 32 | end 33 | -------------------------------------------------------------------------------- /test/fixtures/dual_plot_with_titles.txt: -------------------------------------------------------------------------------- 1 | Histogram - [480218a] Freezin strangs y'all Histogram - [de17c76] m gem 2 | ┌ ┐ ┌ ┐ 3 | [11.0 , 11.08) ┤ 0 [11.0 , 11.08) ┤▇▇▇ 2 4 | [11.08, 11.16) ┤ 0 [11.08, 11.16) ┤ 0 5 | [11.15, 11.23) ┤▇▇▇ 2 [11.15, 11.23) ┤▇▇▇ 2 6 | [11.22, 11.3 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 20 [11.22, 11.3 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 20 7 | Time (s) [11.3 , 11.38) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 24 [11.3 , 11.38) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 24 8 | [11.37, 11.44) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25 [11.37, 11.44) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25 9 | [11.45, 11.52) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 16 [11.45, 11.52) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 16 10 | [11.52, 11.6 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇ 9 [11.52, 11.6 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇ 9 11 | [11.6 , 11.68) ┤▇▇▇▇▇▇▇▇▇ 6 [11.6 , 11.68) ┤▇▇▇▇▇▇ 4 12 | └ ┘ └ ┘ 13 | # of runs in range # of runs in range -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at richard.schneeman+foo@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/mini_histogram.rb: -------------------------------------------------------------------------------- 1 | require "mini_histogram/version" 2 | 3 | # A class for building histogram info 4 | # 5 | # Given an array, this class calculates the "edges" of a histogram 6 | # these edges mark the boundries for "bins" 7 | # 8 | # array = [1,1,1, 5, 5, 5, 5, 10, 10, 10] 9 | # histogram = MiniHistogram.new(array) 10 | # puts histogram.edges 11 | # # => [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0] 12 | # 13 | # It also finds the weights (aka count of values) that would go in each bin: 14 | # 15 | # puts histogram.weights 16 | # # => [3, 0, 4, 0, 0, 3] 17 | # 18 | # This means that the `array` here had three items between 0.0 and 2.0. 19 | # 20 | class MiniHistogram 21 | class Error < StandardError; end 22 | attr_reader :array, :left_p, :max 23 | 24 | def initialize(array, left_p: true, edges: nil) 25 | @array = array 26 | @left_p = left_p 27 | @edges = edges 28 | @weights = nil 29 | 30 | @min, @max = array.minmax 31 | end 32 | 33 | def edges_min 34 | edges.min 35 | end 36 | 37 | def edges_max 38 | edges.max 39 | end 40 | 41 | def histogram(*_) 42 | self 43 | end 44 | 45 | def closed 46 | @left_p ? :left : :right 47 | end 48 | 49 | # Sets the edge value to something new, 50 | # also clears any previously calculated values 51 | def update_values(edges:, max: ) 52 | @edges = edges 53 | @max = max 54 | @weights = nil # clear memoized value 55 | end 56 | 57 | def bin_size 58 | return 0 if edges.length <= 1 59 | 60 | edges[1] - edges[0] 61 | end 62 | 63 | # Weird name, right? There are multiple ways to 64 | # calculate the number of "bins" a histogram should have, one 65 | # of the most common is the "sturges" method 66 | # 67 | # Here are some alternatives from numpy: 68 | # https://github.com/numpy/numpy/blob/d9b1e32cb8ef90d6b4a47853241db2a28146a57d/numpy/lib/histograms.py#L489-L521 69 | def sturges 70 | len = array.length 71 | return 1.0 if len == 0 72 | 73 | # return (long)(ceil(Math.log2(n)) + 1); 74 | return Math.log2(len).ceil + 1 75 | end 76 | 77 | # Given an array of edges and an array we want to generate a histogram from 78 | # return the counts for each "bin" 79 | # 80 | # Example: 81 | # 82 | # a = [1,1,1, 5, 5, 5, 5, 10, 10, 10] 83 | # edges = [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0] 84 | # 85 | # MiniHistogram.new(a).weights 86 | # # => [3, 0, 4, 0, 0, 3] 87 | # 88 | # This means that the `a` array has 3 values between 0.0 and 2.0 89 | # 4 values between 4.0 and 6.0 and three values between 10.0 and 12.0 90 | def weights 91 | return @weights if @weights 92 | return @weights = [] if array.empty? 93 | 94 | lo = edges.first 95 | step = edges[1] - edges[0] 96 | 97 | max_index = ((@max - lo) / step).floor 98 | @weights = Array.new(max_index + 1, 0) 99 | 100 | array.each do |x| 101 | index = ((x - lo) / step).floor 102 | @weights[index] += 1 103 | end 104 | 105 | return @weights 106 | end 107 | 108 | # Finds the "edges" of a given histogram that will mark the boundries 109 | # for the histogram's "bins" 110 | # 111 | # Example: 112 | # 113 | # a = [1,1,1, 5, 5, 5, 5, 10, 10, 10] 114 | # MiniHistogram.new(a).edges 115 | # # => [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0] 116 | # 117 | # There are multiple ways to find edges, this was taken from 118 | # https://github.com/mrkn/enumerable-statistics/issues/24 119 | # 120 | # Another good set of implementations is in numpy 121 | # https://github.com/numpy/numpy/blob/d9b1e32cb8ef90d6b4a47853241db2a28146a57d/numpy/lib/histograms.py#L222 122 | def edges 123 | return @edges if @edges 124 | 125 | return @edges = [0.0] if array.empty? 126 | 127 | lo = @min 128 | hi = @max 129 | 130 | nbins = sturges.to_f 131 | 132 | if hi == lo 133 | start = lo 134 | step = 1.0 135 | divisor = 1.0 136 | len = 1 137 | else 138 | bw = (hi - lo) / nbins 139 | lbw = Math.log10(bw) 140 | if lbw >= 0 141 | step = 10 ** lbw.floor * 1.0 142 | r = bw/step 143 | 144 | if r <= 1.1 145 | # do nothing 146 | elsif r <= 2.2 147 | step *= 2.0 148 | elsif r <= 5.5 149 | step *= 5.0 150 | else 151 | step *= 10 152 | end 153 | divisor = 1.0 154 | start = step * (lo/step).floor 155 | len = ((hi - start)/step).ceil 156 | else 157 | divisor = 10 ** - lbw.floor 158 | r = bw * divisor 159 | if r <= 1.1 160 | # do nothing 161 | elsif r <= 2.2 162 | divisor /= 2.0 163 | elsif r <= 5.5 164 | divisor /= 5.0 165 | else 166 | divisor /= 10.0 167 | end 168 | step = 1.0 169 | start = (lo * divisor).floor 170 | len = (hi * divisor - start).ceil 171 | end 172 | end 173 | 174 | if left_p 175 | while (lo < start/divisor) 176 | start -= step 177 | end 178 | 179 | while (start + (len - 1)*step)/divisor <= hi 180 | len += 1 181 | end 182 | else 183 | while lo <= start/divisor 184 | start -= step 185 | end 186 | while (start + (len - 1)*step)/divisor < hi 187 | len += 1 188 | end 189 | end 190 | 191 | @edges = [] 192 | len.times.each do 193 | @edges << start/divisor 194 | start += step 195 | end 196 | 197 | return @edges 198 | end 199 | alias :edge :edges 200 | 201 | def plot 202 | raise "You must `require 'mini_histogram/plot'` to get this feature" 203 | end 204 | 205 | # Given an array of Histograms this function calcualtes 206 | # an average edge size along with the minimum and maximum 207 | # edge values. It then updates the edge value on all inputs 208 | # 209 | # The main pourpose of this method is to be able to chart multiple 210 | # distributions against a similar axis 211 | # 212 | # See for more context: https://github.com/schneems/derailed_benchmarks/pull/169 213 | def self.set_average_edges!(*array_of_histograms) 214 | array_of_histograms.each { |x| raise "Input expected to be a histogram but is #{x.inspect}" unless x.is_a?(MiniHistogram) } 215 | steps = array_of_histograms.map(&:bin_size) 216 | avg_step_size = steps.inject(&:+).to_f / steps.length 217 | 218 | max_value = array_of_histograms.map(&:max).max 219 | 220 | max_edge = array_of_histograms.map(&:edges_max).max 221 | min_edge = array_of_histograms.map(&:edges_min).min 222 | 223 | average_edges = [min_edge] 224 | while average_edges.last < max_edge 225 | average_edges << average_edges.last + avg_step_size 226 | end 227 | 228 | array_of_histograms.each {|h| h.update_values(edges: average_edges, max: max_value) } 229 | 230 | return array_of_histograms 231 | end 232 | end 233 | 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MiniHistogram [![Build Status](https://travis-ci.org/zombocom/mini_histogram.svg?branch=master)](https://travis-ci.org/zombocom/mini_histogram) 2 | 3 | What's a histogram and why should you care? First read [Lies, Damned Lies, and Averages: Perc50, Perc95 explained for Programmers](https://schneems.com/2020/03/17/lies-damned-lies-and-averages-perc50-perc95-explained-for-programmers/). This library lets you build histograms in pure Ruby. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'mini_histogram' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle install 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install mini_histogram 20 | 21 | ## Usage 22 | 23 | Given an array, this class calculates the "edges" of a histogram these edges mark the boundries for "bins" 24 | 25 | ```ruby 26 | array = [1,1,1, 5, 5, 5, 5, 10, 10, 10] 27 | histogram = MiniHistogram.new(array) 28 | puts histogram.edges 29 | # => [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0] 30 | ``` 31 | 32 | It also finds the weights (aka count of values) that would go in each bin: 33 | 34 | ``` 35 | puts histogram.weights 36 | # => [3, 0, 4, 0, 0, 3] 37 | ``` 38 | 39 | This means that the `array` here had three items between 0.0 and 2.0, four items between 4.0 and 6.0 and three items between 10.0 and 12.0 40 | 41 | ## Plotting [experimental] 42 | 43 | You can plot! 44 | 45 | ```ruby 46 | require 'mini_histogram/plot' 47 | array = 50.times.map { rand(11.2..11.6) } 48 | histogram = MiniHistogram.new(array) 49 | puts histogram.plot 50 | ``` 51 | 52 | Will generate: 53 | 54 | ``` 55 | ┌ ┐ 56 | [11.2 , 11.25) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 9 57 | [11.25, 11.3 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 6 58 | [11.3 , 11.35) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇ 4 59 | [11.35, 11.4 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇ 4 60 | [11.4 , 11.45) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 11 61 | [11.45, 11.5 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 5 62 | [11.5 , 11.55) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 7 63 | [11.55, 11.6 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇ 4 64 | └ ┘ 65 | Frequency 66 | ``` 67 | 68 | Integrated plotting is an experimental currently, use with some caution. If you are on Ruby 2.4+ you can pass an instance of MiniHistogram to [unicode_plot.rb](https://github.com/red-data-tools/unicode_plot.rb): 69 | 70 | ```ruby 71 | array = 50.times.map { rand(11.2..11.6) } 72 | histogram = MiniHistogram.new(array) 73 | puts UnicodePlot.histogram(histogram) 74 | ``` 75 | 76 | ## Plotting dualing histograms [experimental] 77 | 78 | If you're plotting multiple histograms (first, please normalize the bucket sizes), second. It can be hard to compare them vertically. Here's an example: 79 | 80 | ``` 81 | ┌ ┐ 82 | [11.2 , 11.28) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 12 83 | [11.28, 11.36) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 22 84 | [11.35, 11.43) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 30 85 | [11.43, 11.51) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 17 86 | [11.5 , 11.58) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 13 87 | [11.58, 11.66) ┤▇▇▇▇▇▇▇ 6 88 | [11.65, 11.73) ┤ 0 89 | [11.73, 11.81) ┤ 0 90 | [11.8 , 11.88) ┤ 0 91 | └ ┘ 92 | Frequency 93 | ┌ ┐ 94 | [11.2 , 11.28) ┤▇▇▇▇ 3 95 | [11.28, 11.36) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 19 96 | [11.35, 11.43) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 17 97 | [11.43, 11.51) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25 98 | [11.5 , 11.58) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 15 99 | [11.58, 11.66) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 13 100 | [11.65, 11.73) ┤▇▇▇▇ 3 101 | [11.73, 11.81) ┤▇▇▇▇ 3 102 | [11.8 , 11.88) ┤▇▇▇ 2 103 | └ ┘ 104 | Frequency 105 | ``` 106 | 107 | Here's the same data set plotted side-by-side: 108 | 109 | ``` 110 | ┌ ┐ ┌ ┐ 111 | [11.2 , 11.28) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 12 [11.2 , 11.28) ┤▇▇▇▇ 3 112 | [11.28, 11.36) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 22 [11.28, 11.36) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 19 113 | [11.35, 11.43) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 30 [11.35, 11.43) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 17 114 | [11.43, 11.51) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 17 [11.43, 11.51) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25 115 | [11.5 , 11.58) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 13 [11.5 , 11.58) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 15 116 | [11.58, 11.66) ┤▇▇▇▇▇▇▇ 6 [11.58, 11.66) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 13 117 | [11.65, 11.73) ┤ 0 [11.65, 11.73) ┤▇▇▇▇ 3 118 | [11.73, 11.81) ┤ 0 [11.73, 11.81) ┤▇▇▇▇ 3 119 | [11.8 , 11.88) ┤ 0 [11.8 , 11.88) ┤▇▇▇ 2 120 | └ ┘ └ ┘ 121 | Frequency Frequency 122 | ``` 123 | 124 | This method might require more scrolling in the github issue, but makes it easier to compare two distributions. Here's how you plot dualing histograms: 125 | 126 | ```ruby 127 | require 'mini_histogram/plot' 128 | 129 | a = MiniHistogram.new [11.205184, 11.223665, 11.228286, 11.23219, 11.233325, 11.234516, 11.245781, 11.248441, 11.250758, 11.255686, 11.265876, 11.26641, 11.279456, 11.281067, 11.284281, 11.287656, 11.289316, 11.289682, 11.292289, 11.294518, 11.296454, 11.299277, 11.305801, 11.306602, 11.309311, 11.318465, 11.318477, 11.322258, 11.328267, 11.334188, 11.339722, 11.340585, 11.346084, 11.346197, 11.351863, 11.35982, 11.362358, 11.364476, 11.365743, 11.368492, 11.368566, 11.36869, 11.37268, 11.374204, 11.374217, 11.374955, 11.376422, 11.377989, 11.383357, 11.383593, 11.385184, 11.394766, 11.395829, 11.398455, 11.399739, 11.401304, 11.411387, 11.411978, 11.413585, 11.413659, 11.418504, 11.419194, 11.419415, 11.421374, 11.4261, 11.427901, 11.429651, 11.434272, 11.435012, 11.440848, 11.447495, 11.456107, 11.457434, 11.467112, 11.471005, 11.473235, 11.485025, 11.485852, 11.488256, 11.488275, 11.499545, 11.509588, 11.51378, 11.51544, 11.520783, 11.52246, 11.522855, 11.5322, 11.533764, 11.544047, 11.552597, 11.558062, 11.567239, 11.569749, 11.575796, 11.588014, 11.614032, 11.615062, 11.618194, 11.635267] 130 | b = MiniHistogram.new [11.233813, 11.240717, 11.254617, 11.282013, 11.290658, 11.303213, 11.305237, 11.305299, 11.306397, 11.313867, 11.31397, 11.314444, 11.318032, 11.328111, 11.330127, 11.333235, 11.33678, 11.337799, 11.343758, 11.347798, 11.347915, 11.349594, 11.358198, 11.358507, 11.3628, 11.366111, 11.374993, 11.378195, 11.38166, 11.384867, 11.385235, 11.395825, 11.404434, 11.406065, 11.406677, 11.410244, 11.414527, 11.421267, 11.424535, 11.427231, 11.427869, 11.428548, 11.432594, 11.433524, 11.434903, 11.437769, 11.439761, 11.443437, 11.443846, 11.451106, 11.458503, 11.462256, 11.462324, 11.464342, 11.464716, 11.46477, 11.465271, 11.466843, 11.468789, 11.475492, 11.488113, 11.489616, 11.493736, 11.496842, 11.502074, 11.511367, 11.512634, 11.515562, 11.525771, 11.531415, 11.535379, 11.53966, 11.540969, 11.541265, 11.541978, 11.545301, 11.545533, 11.545701, 11.572584, 11.578881, 11.580701, 11.580922, 11.588731, 11.594082, 11.595915, 11.613622, 11.619884, 11.632889, 11.64377, 11.645225, 11.647167, 11.648257, 11.667158, 11.670378, 11.681261, 11.734586, 11.747066, 11.792425, 11.808377, 11.812346] 131 | 132 | dual_histogram = MiniHistogram.dual_plot do |x, y| 133 | x.histogram = a 134 | x.options = {} 135 | y.histogram = b 136 | y.options = {} 137 | end 138 | puts dual_histogram 139 | ``` 140 | 141 | 142 | ## Alternatives 143 | 144 | Alternatives to this gem include https://github.com/mrkn/enumerable-statistics/. I needed this gem to be able to calculate a "shared" or "average" edge value as seen in this PR https://github.com/mrkn/enumerable-statistics/pull/23. So that I could add histograms to derailed benchmarks: https://github.com/schneems/derailed_benchmarks/pull/169. This gem provides a `MiniHistogram.set_average_edges!` method to help there. Also this gem does not require a native extension compilation (faster to install, but performance is slower), and this gem does not extend or monkeypatch an core classes. 145 | 146 | [MiniHistogram API Docs](https://rubydoc.info/github/zombocom/mini_histogram/master/MiniHistogram) 147 | 148 | ## Development 149 | 150 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 151 | 152 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 153 | 154 | ## Contributing 155 | 156 | Bug reports and pull requests are welcome on GitHub at https://github.com/zombocom/mini_histogram. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/zombocom/mini_histogram/blob/master/CODE_OF_CONDUCT.md). 157 | 158 | ## License 159 | 160 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 161 | 162 | ## Code of Conduct 163 | 164 | Everyone interacting in the MiniHistogram project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/zombocom/mini_histogram/blob/master/CODE_OF_CONDUCT.md). 165 | -------------------------------------------------------------------------------- /test/mini_histogram_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | require 'tempfile' 4 | require "mini_histogram/plot" 5 | 6 | class MiniHistogramTest < Minitest::Test 7 | def test_that_it_has_a_version_number 8 | refute_nil ::MiniHistogram::VERSION 9 | end 10 | 11 | def test_plot_requires 12 | file = Tempfile.new("plot.rb") 13 | file.write <<-EOM 14 | STDOUT.sync = true 15 | $LOAD_PATH.unshift '#{MINI_HISTOGRAM_LIB_DIR}' 16 | require 'mini_histogram/plot' 17 | 18 | puts MiniHistogram.new([1,2,3,4,5]).plot 19 | EOM 20 | file.close 21 | 22 | out = `ruby #{file.path}` 23 | assert_match "Frequency", out 24 | assert $?.success? 25 | end 26 | 27 | def test_plot 28 | a = MiniHistogram.new [11.205184, 11.223665, 11.228286, 11.23219, 11.233325, 11.234516, 11.245781, 11.248441, 11.250758, 11.255686, 11.265876, 11.26641, 11.279456, 11.281067, 11.284281, 11.287656, 11.289316, 11.289682, 11.292289, 11.294518, 11.296454, 11.299277, 11.305801, 11.306602, 11.309311, 11.318465, 11.318477, 11.322258, 11.328267, 11.334188, 11.339722, 11.340585, 11.346084, 11.346197, 11.351863, 11.35982, 11.362358, 11.364476, 11.365743, 11.368492, 11.368566, 11.36869, 11.37268, 11.374204, 11.374217, 11.374955, 11.376422, 11.377989, 11.383357, 11.383593, 11.385184, 11.394766, 11.395829, 11.398455, 11.399739, 11.401304, 11.411387, 11.411978, 11.413585, 11.413659, 11.418504, 11.419194, 11.419415, 11.421374, 11.4261, 11.427901, 11.429651, 11.434272, 11.435012, 11.440848, 11.447495, 11.456107, 11.457434, 11.467112, 11.471005, 11.473235, 11.485025, 11.485852, 11.488256, 11.488275, 11.499545, 11.509588, 11.51378, 11.51544, 11.520783, 11.52246, 11.522855, 11.5322, 11.533764, 11.544047, 11.552597, 11.558062, 11.567239, 11.569749, 11.575796, 11.588014, 11.614032, 11.615062, 11.618194, 11.635267] 29 | 30 | actual = a.plot 31 | expected = File.read("test/fixtures/histogram_first.txt") 32 | 33 | assert_equal expected, actual.to_s 34 | end 35 | 36 | def test_dual_plot 37 | actual = MiniHistogram.dual_plot do |a, b| 38 | a.histogram = MiniHistogram.new [11.205184, 11.223665, 11.228286, 11.23219, 11.233325, 11.234516, 11.245781, 11.248441, 11.250758, 11.255686, 11.265876, 11.26641, 11.279456, 11.281067, 11.284281, 11.287656, 11.289316, 11.289682, 11.292289, 11.294518, 11.296454, 11.299277, 11.305801, 11.306602, 11.309311, 11.318465, 11.318477, 11.322258, 11.328267, 11.334188, 11.339722, 11.340585, 11.346084, 11.346197, 11.351863, 11.35982, 11.362358, 11.364476, 11.365743, 11.368492, 11.368566, 11.36869, 11.37268, 11.374204, 11.374217, 11.374955, 11.376422, 11.377989, 11.383357, 11.383593, 11.385184, 11.394766, 11.395829, 11.398455, 11.399739, 11.401304, 11.411387, 11.411978, 11.413585, 11.413659, 11.418504, 11.419194, 11.419415, 11.421374, 11.4261, 11.427901, 11.429651, 11.434272, 11.435012, 11.440848, 11.447495, 11.456107, 11.457434, 11.467112, 11.471005, 11.473235, 11.485025, 11.485852, 11.488256, 11.488275, 11.499545, 11.509588, 11.51378, 11.51544, 11.520783, 11.52246, 11.522855, 11.5322, 11.533764, 11.544047, 11.552597, 11.558062, 11.567239, 11.569749, 11.575796, 11.588014, 11.614032, 11.615062, 11.618194, 11.635267] 39 | b.histogram = MiniHistogram.new [11.205184, 11.223665, 11.228286, 11.23219, 11.233325, 11.234516, 11.245781, 11.248441, 11.250758, 11.255686, 11.265876, 11.26641, 11.279456, 11.281067, 11.284281, 11.287656, 11.289316, 11.289682, 11.292289, 11.294518, 11.296454, 11.299277, 11.305801, 11.306602, 11.309311, 11.318465, 11.318477, 11.322258, 11.328267, 11.334188, 11.339722, 11.340585, 11.346084, 11.346197, 11.351863, 11.35982, 11.362358, 11.364476, 11.365743, 11.368492, 11.368566, 11.36869, 11.37268, 11.374204, 11.374217, 11.374955, 11.376422, 11.377989, 11.383357, 11.383593, 11.385184, 11.394766, 11.395829, 11.398455, 11.399739, 11.401304, 11.411387, 11.411978, 11.413585, 11.413659, 11.418504, 11.419194, 11.419415, 11.421374, 11.4261, 11.427901, 11.429651, 11.434272, 11.435012, 11.440848, 11.447495, 11.456107, 11.457434, 11.467112, 11.471005, 11.473235, 11.485025, 11.485852, 11.488256, 11.488275, 11.499545, 11.509588, 11.51378, 11.51544, 11.520783, 11.52246, 11.522855, 11.5322, 11.533764, 11.544047, 11.552597, 11.558062, 11.567239, 11.569749, 11.575796, 11.588014, 11.614032, 11.615062, 11.618194, 11.635267] 40 | end 41 | 42 | expected = File.read("test/fixtures/side_by_side_histogram.txt") 43 | 44 | assert_equal expected, actual.to_s 45 | end 46 | 47 | def test_dual_plot_with_labels_and_different_values 48 | actual = MiniHistogram.dual_plot do |a, b| 49 | a.values = [11.6, 11.6, 11.205184, 11.223665, 11.228286, 11.23219, 11.233325, 11.234516, 11.245781, 11.248441, 11.250758, 11.255686, 11.265876, 11.26641, 11.279456, 11.281067, 11.284281, 11.287656, 11.289316, 11.289682, 11.292289, 11.294518, 11.296454, 11.299277, 11.305801, 11.306602, 11.309311, 11.318465, 11.318477, 11.322258, 11.328267, 11.334188, 11.339722, 11.340585, 11.346084, 11.346197, 11.351863, 11.35982, 11.362358, 11.364476, 11.365743, 11.368492, 11.368566, 11.36869, 11.37268, 11.374204, 11.374217, 11.374955, 11.376422, 11.377989, 11.383357, 11.383593, 11.385184, 11.394766, 11.395829, 11.398455, 11.399739, 11.401304, 11.411387, 11.411978, 11.413585, 11.413659, 11.418504, 11.419194, 11.419415, 11.421374, 11.4261, 11.427901, 11.429651, 11.434272, 11.435012, 11.440848, 11.447495, 11.456107, 11.457434, 11.467112, 11.471005, 11.473235, 11.485025, 11.485852, 11.488256, 11.488275, 11.499545, 11.509588, 11.51378, 11.51544, 11.520783, 11.52246, 11.522855, 11.5322, 11.533764, 11.544047, 11.552597, 11.558062, 11.567239, 11.569749, 11.575796, 11.588014, 11.614032, 11.615062, 11.618194, 11.635267] 50 | a.options = { 51 | title: "Histogram - [480218a] Freezin strangs y'all", 52 | ylabel: "Time (s)", 53 | xlabel: "# of runs in range" 54 | } 55 | 56 | b.values = [11.0, 11.0, 11.205184, 11.223665, 11.228286, 11.23219, 11.233325, 11.234516, 11.245781, 11.248441, 11.250758, 11.255686, 11.265876, 11.26641, 11.279456, 11.281067, 11.284281, 11.287656, 11.289316, 11.289682, 11.292289, 11.294518, 11.296454, 11.299277, 11.305801, 11.306602, 11.309311, 11.318465, 11.318477, 11.322258, 11.328267, 11.334188, 11.339722, 11.340585, 11.346084, 11.346197, 11.351863, 11.35982, 11.362358, 11.364476, 11.365743, 11.368492, 11.368566, 11.36869, 11.37268, 11.374204, 11.374217, 11.374955, 11.376422, 11.377989, 11.383357, 11.383593, 11.385184, 11.394766, 11.395829, 11.398455, 11.399739, 11.401304, 11.411387, 11.411978, 11.413585, 11.413659, 11.418504, 11.419194, 11.419415, 11.421374, 11.4261, 11.427901, 11.429651, 11.434272, 11.435012, 11.440848, 11.447495, 11.456107, 11.457434, 11.467112, 11.471005, 11.473235, 11.485025, 11.485852, 11.488256, 11.488275, 11.499545, 11.509588, 11.51378, 11.51544, 11.520783, 11.52246, 11.522855, 11.5322, 11.533764, 11.544047, 11.552597, 11.558062, 11.567239, 11.569749, 11.575796, 11.588014, 11.614032, 11.615062, 11.618194, 11.635267] 57 | b.options = { 58 | title: "Histogram - [de17c76] m gem", 59 | ylabel: "Time (s)", 60 | xlabel: "# of runs in range" 61 | } 62 | end 63 | 64 | expected = File.read("test/fixtures/dual_plot_with_titles.txt") 65 | assert_equal expected, actual.to_s 66 | end 67 | 68 | def test_plot_with_integers 69 | a = MiniHistogram.new [1, 2, 3] 70 | 71 | a.plot 72 | end 73 | 74 | def test_corner_cases 75 | expected_edge = [0.0] 76 | expected_weights = [] 77 | 78 | a = MiniHistogram.new [] 79 | assert_equal expected_edge, a.edges 80 | assert_equal expected_weights, a.weights 81 | 82 | MiniHistogram.set_average_edges!(a, a) 83 | assert_equal expected_edge, a.edges 84 | assert_equal expected_weights, a.weights 85 | 86 | expected_edge = [1.1, 2.1] 87 | expected_weights = [1] 88 | 89 | a = MiniHistogram.new [1.1] 90 | assert_equal expected_edge, a.edges 91 | assert_equal expected_weights, a.weights 92 | 93 | MiniHistogram.set_average_edges!(a, a) 94 | assert_equal expected_edge, a.edges 95 | assert_equal expected_weights, a.weights 96 | end 97 | 98 | def test_averaging_edges_has_the_same_weight_and_edge_length 99 | a = MiniHistogram.new [11.205184, 11.223665, 11.228286, 11.23219, 11.233325, 11.234516, 11.245781, 11.248441, 11.250758, 11.255686, 11.265876, 11.26641, 11.279456, 11.281067, 11.284281, 11.287656, 11.289316, 11.289682, 11.292289, 11.294518, 11.296454, 11.299277, 11.305801, 11.306602, 11.309311, 11.318465, 11.318477, 11.322258, 11.328267, 11.334188, 11.339722, 11.340585, 11.346084, 11.346197, 11.351863, 11.35982, 11.362358, 11.364476, 11.365743, 11.368492, 11.368566, 11.36869, 11.37268, 11.374204, 11.374217, 11.374955, 11.376422, 11.377989, 11.383357, 11.383593, 11.385184, 11.394766, 11.395829, 11.398455, 11.399739, 11.401304, 11.411387, 11.411978, 11.413585, 11.413659, 11.418504, 11.419194, 11.419415, 11.421374, 11.4261, 11.427901, 11.429651, 11.434272, 11.435012, 11.440848, 11.447495, 11.456107, 11.457434, 11.467112, 11.471005, 11.473235, 11.485025, 11.485852, 11.488256, 11.488275, 11.499545, 11.509588, 11.51378, 11.51544, 11.520783, 11.52246, 11.522855, 11.5322, 11.533764, 11.544047, 11.552597, 11.558062, 11.567239, 11.569749, 11.575796, 11.588014, 11.614032, 11.615062, 11.618194, 11.635267] 100 | b = MiniHistogram.new [11.233813, 11.240717, 11.254617, 11.282013, 11.290658, 11.303213, 11.305237, 11.305299, 11.306397, 11.313867, 11.31397, 11.314444, 11.318032, 11.328111, 11.330127, 11.333235, 11.33678, 11.337799, 11.343758, 11.347798, 11.347915, 11.349594, 11.358198, 11.358507, 11.3628, 11.366111, 11.374993, 11.378195, 11.38166, 11.384867, 11.385235, 11.395825, 11.404434, 11.406065, 11.406677, 11.410244, 11.414527, 11.421267, 11.424535, 11.427231, 11.427869, 11.428548, 11.432594, 11.433524, 11.434903, 11.437769, 11.439761, 11.443437, 11.443846, 11.451106, 11.458503, 11.462256, 11.462324, 11.464342, 11.464716, 11.46477, 11.465271, 11.466843, 11.468789, 11.475492, 11.488113, 11.489616, 11.493736, 11.496842, 11.502074, 11.511367, 11.512634, 11.515562, 11.525771, 11.531415, 11.535379, 11.53966, 11.540969, 11.541265, 11.541978, 11.545301, 11.545533, 11.545701, 11.572584, 11.578881, 11.580701, 11.580922, 11.588731, 11.594082, 11.595915, 11.613622, 11.619884, 11.632889, 11.64377, 11.645225, 11.647167, 11.648257, 11.667158, 11.670378, 11.681261, 11.734586, 11.747066, 11.792425, 11.808377, 11.812346] 101 | 102 | MiniHistogram.set_average_edges!(a, b) 103 | 104 | assert_equal a.edges.length, b.edges.length 105 | assert_equal a.weights.length, b.weights.length 106 | end 107 | 108 | def test_average_edges 109 | a = MiniHistogram.new [1,1,1, 5, 5, 5, 5, 10, 10, 10] 110 | b = MiniHistogram.new [7, 7, 7, 12, 12, 12, 12, 20, 20, 20] 111 | 112 | MiniHistogram.set_average_edges!(a, b) 113 | 114 | expected = [0.0, 3.5, 7.0, 10.5, 14.0, 17.5, 21.0, 24.5, 28.0] 115 | assert_equal expected, a.edges 116 | assert_equal expected, b.edges 117 | assert_equal expected, b.edge 118 | end 119 | 120 | def test_weights 121 | a = [1,1,1, 5, 5, 5, 5, 10, 10, 10] 122 | edges = [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0] 123 | actual = MiniHistogram.new(a, edges: edges).weights 124 | expected = [3, 0, 4, 0, 0, 3] 125 | assert_equal expected, actual 126 | 127 | actual = MiniHistogram.new(a).weights 128 | assert_equal expected, actual 129 | end 130 | 131 | def test_find_edges 132 | a = [1,1,1, 5, 5, 5, 5, 10, 10, 10] 133 | actual = MiniHistogram.new(a).sturges 134 | assert_equal 5, actual 135 | 136 | actual = MiniHistogram.new(a).edges 137 | expected = [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0] 138 | assert_equal expected, actual 139 | 140 | b = [7, 7, 7, 12, 12, 12, 12, 20, 20, 20] 141 | actual = MiniHistogram.new(b).edges 142 | expected = [5.0, 10.0, 15.0, 20.0, 25.0] 143 | assert_equal expected, actual 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/mini_histogram/plot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'stringio' 4 | require_relative '../mini_histogram' # allows people to require 'mini_histogram/plot' directly 5 | 6 | # Plots the histogram in unicode characters 7 | # 8 | # Thanks to https://github.com/red-data-tools/unicode_plot.rb 9 | # it could not be used because the dependency enumerable-statistics has a hard 10 | # lock on a specific version of Ruby and this library needs to support older Rubies 11 | # 12 | # Example: 13 | # 14 | # require 'mini_histogram/plot' 15 | # array = 50.times.map { rand(11.2..11.6) } 16 | # histogram = MiniHistogram.new(array) 17 | # puts histogram.plot => Generates a plot 18 | # 19 | class MiniHistogram 20 | 21 | # This is an object that holds a histogram 22 | # and it's corresponding plot options 23 | # 24 | # Example: 25 | # 26 | # x = PlotValue.new 27 | # x.values = [1,2,3,4,5] 28 | # x.options = {xlabel: "random"} 29 | # 30 | # x.plot # => Generates a histogram plot with these values and options 31 | class PlotValue 32 | attr_accessor :histogram, :options 33 | 34 | def initialize 35 | @histogram = nil 36 | @options = {} 37 | end 38 | 39 | def plot 40 | raise "@histogram cannot be empty set via `values=` or `histogram=` methods" if @histogram.nil? 41 | 42 | @histogram.plot(**@options) 43 | end 44 | 45 | def values=(values) 46 | @histogram = MiniHistogram.new(values) 47 | end 48 | 49 | def self.dual_plot(plot_a, plot_b) 50 | a_lines = plot_a.to_s.lines 51 | b_lines = plot_b.to_s.lines 52 | 53 | max_length = a_lines.map(&:length).max 54 | 55 | side_by_side = String.new("") 56 | a_lines.each_index do |i| 57 | side_by_side << a_lines[i].chomp.ljust(max_length) # Remove newline, ensure same length 58 | side_by_side << b_lines[i] 59 | end 60 | 61 | return side_by_side 62 | end 63 | end 64 | private_constant :PlotValue 65 | 66 | def self.dual_plot 67 | a = PlotValue.new 68 | b = PlotValue.new 69 | 70 | yield a, b 71 | 72 | if b.options[:ylabel] == a.options[:ylabel] 73 | b.options[:ylabel] = nil 74 | end 75 | 76 | MiniHistogram.set_average_edges!(a.histogram, b.histogram) 77 | PlotValue.dual_plot(a.plot, b.plot) 78 | end 79 | 80 | def plot( 81 | nbins: nil, 82 | closed: :left, 83 | symbol: "▇", 84 | **kw) 85 | hist = self.histogram(*[nbins].compact, closed: closed) 86 | edge, counts = hist.edge, hist.weights 87 | labels = [] 88 | bin_width = edge[1] - edge[0] 89 | pad_left, pad_right = 0, 0 90 | (0 ... edge.length).each do |i| 91 | val1 = float_round_log10(edge[i], bin_width) 92 | val2 = float_round_log10(val1 + bin_width, bin_width) 93 | a1 = val1.to_s.split('.', 2).map(&:length) 94 | a2 = val2.to_s.split('.', 2).map(&:length) 95 | pad_left = [pad_left, a1[0], a2[0]].max 96 | pad_right = [pad_right, a1[1], a2[1]].max 97 | end 98 | l_str = hist.closed == :right ? "(" : "[" 99 | r_str = hist.closed == :right ? "]" : ")" 100 | counts.each_with_index do |n, i| 101 | val1 = float_round_log10(edge[i], bin_width) 102 | val2 = float_round_log10(val1 + bin_width, bin_width) 103 | a1 = val1.to_s.split('.', 2).map(&:length) 104 | a2 = val2.to_s.split('.', 2).map(&:length) 105 | labels[i] = "\e[90m#{l_str}\e[0m" + 106 | (" " * (pad_left - a1[0])) + 107 | val1.to_s + 108 | (" " * (pad_right - a1[1])) + 109 | "\e[90m, \e[0m" + 110 | (" " * (pad_left - a2[0])) + 111 | val2.to_s + 112 | (" " * (pad_right - a2[1])) + 113 | "\e[90m#{r_str}\e[0m" 114 | end 115 | xscale = kw.delete(:xscale) 116 | xlabel = kw.delete(:xlabel) || MiniUnicodePlot::ValueTransformer.transform_name(xscale, "Frequency") 117 | barplot(labels, counts, 118 | symbol: symbol, 119 | xscale: xscale, 120 | xlabel: xlabel, 121 | **kw) 122 | end 123 | 124 | ## Begin copy/pasta from unicode_plot.rb with some slight modifications 125 | private def barplot( 126 | *args, 127 | width: 40, 128 | color: :green, 129 | symbol: "■", 130 | border: :barplot, 131 | xscale: nil, 132 | xlabel: nil, 133 | data: nil, 134 | **kw) 135 | case args.length 136 | when 0 137 | data = Hash(data) 138 | keys = data.keys.map(&:to_s) 139 | heights = data.values 140 | when 2 141 | keys = Array(args[0]) 142 | heights = Array(args[1]) 143 | else 144 | raise ArgumentError, "invalid arguments" 145 | end 146 | 147 | unless keys.length == heights.length 148 | raise ArgumentError, "The given vectors must be of the same length" 149 | end 150 | unless heights.min >= 0 151 | raise ArgumentError, "All values have to be positive. Negative bars are not supported." 152 | end 153 | 154 | xlabel ||= ValueTransformer.transform_name(xscale) 155 | plot = MiniUnicodePlot::Barplot.new(heights, width, color, symbol, xscale, 156 | border: border, xlabel: xlabel, 157 | **kw) 158 | keys.each_with_index do |key, i| 159 | plot.annotate_row!(:l, i, key) 160 | end 161 | 162 | plot 163 | end 164 | 165 | private def float_round_log10(x, m) 166 | if x == 0 167 | 0.0 168 | elsif x > 0 169 | x.round(ceil_neg_log10(m) + 1).to_f 170 | else 171 | -(-x).round(ceil_neg_log10(m) + 1).to_f 172 | end 173 | end 174 | 175 | private def ceil_neg_log10(x) 176 | if roundable?(-Math.log10(x)) 177 | (-Math.log10(x)).ceil 178 | else 179 | (-Math.log10(x)).floor 180 | end 181 | end 182 | 183 | INT64_MIN = -9223372036854775808 184 | INT64_MAX = 9223372036854775807 185 | private def roundable?(x) 186 | x.to_i == x && INT64_MIN <= x && x < INT64_MAX 187 | end 188 | 189 | module MiniUnicodePlot 190 | module ValueTransformer 191 | PREDEFINED_TRANSFORM_FUNCTIONS = { 192 | log: Math.method(:log), 193 | ln: Math.method(:log), 194 | log10: Math.method(:log10), 195 | lg: Math.method(:log10), 196 | log2: Math.method(:log2), 197 | lb: Math.method(:log2), 198 | }.freeze 199 | 200 | def transform_values(func, values) 201 | return values unless func 202 | 203 | unless func.respond_to?(:call) 204 | func = PREDEFINED_TRANSFORM_FUNCTIONS[func] 205 | unless func.respond_to?(:call) 206 | raise ArgumentError, "func must be callable" 207 | end 208 | end 209 | 210 | case values 211 | when Numeric 212 | func.(values) 213 | else 214 | values.map(&func) 215 | end 216 | end 217 | 218 | module_function def transform_name(func, basename="") 219 | return basename unless func 220 | case func 221 | when String, Symbol 222 | name = func 223 | when ->(f) { f.respond_to?(:name) } 224 | name = func.name 225 | else 226 | name = "custom" 227 | end 228 | "#{basename} [#{name}]" 229 | end 230 | end 231 | 232 | 233 | module BorderMaps 234 | BORDER_SOLID = { 235 | tl: "┌", 236 | tr: "┐", 237 | bl: "└", 238 | br: "┘", 239 | t: "─", 240 | l: "│", 241 | b: "─", 242 | r: "│" 243 | }.freeze 244 | 245 | BORDER_CORNERS = { 246 | tl: "┌", 247 | tr: "┐", 248 | bl: "└", 249 | br: "┘", 250 | t: " ", 251 | l: " ", 252 | b: " ", 253 | r: " ", 254 | }.freeze 255 | 256 | BORDER_BARPLOT = { 257 | tl: "┌", 258 | tr: "┐", 259 | bl: "└", 260 | br: "┘", 261 | t: " ", 262 | l: "┤", 263 | b: " ", 264 | r: " ", 265 | }.freeze 266 | end 267 | 268 | BORDER_MAP = { 269 | solid: BorderMaps::BORDER_SOLID, 270 | corners: BorderMaps::BORDER_CORNERS, 271 | barplot: BorderMaps::BORDER_BARPLOT, 272 | }.freeze 273 | 274 | module StyledPrinter 275 | TEXT_COLORS = { 276 | black: "\033[30m", 277 | red: "\033[31m", 278 | green: "\033[32m", 279 | yellow: "\033[33m", 280 | blue: "\033[34m", 281 | magenta: "\033[35m", 282 | cyan: "\033[36m", 283 | white: "\033[37m", 284 | gray: "\033[90m", 285 | light_black: "\033[90m", 286 | light_red: "\033[91m", 287 | light_green: "\033[92m", 288 | light_yellow: "\033[93m", 289 | light_blue: "\033[94m", 290 | light_magenta: "\033[95m", 291 | light_cyan: "\033[96m", 292 | normal: "\033[0m", 293 | default: "\033[39m", 294 | bold: "\033[1m", 295 | underline: "\033[4m", 296 | blink: "\033[5m", 297 | reverse: "\033[7m", 298 | hidden: "\033[8m", 299 | nothing: "", 300 | } 301 | 302 | 0.upto(255) do |i| 303 | TEXT_COLORS[i] = "\033[38;5;#{i}m" 304 | end 305 | 306 | TEXT_COLORS.freeze 307 | 308 | DISABLE_TEXT_STYLE = { 309 | bold: "\033[22m", 310 | underline: "\033[24m", 311 | blink: "\033[25m", 312 | reverse: "\033[27m", 313 | hidden: "\033[28m", 314 | normal: "", 315 | default: "", 316 | nothing: "", 317 | }.freeze 318 | 319 | COLOR_ENCODE = { 320 | normal: 0b000, 321 | blue: 0b001, 322 | red: 0b010, 323 | magenta: 0b011, 324 | green: 0b100, 325 | cyan: 0b101, 326 | yellow: 0b110, 327 | white: 0b111 328 | }.freeze 329 | 330 | COLOR_DECODE = COLOR_ENCODE.map {|k, v| [v, k] }.to_h.freeze 331 | 332 | def print_styled(out, *args, bold: false, color: :normal) 333 | return out.print(*args) unless color?(out) 334 | 335 | str = StringIO.open {|sio| sio.print(*args); sio.close; sio.string } 336 | color = :nothing if bold && color == :bold 337 | enable_ansi = TEXT_COLORS.fetch(color, TEXT_COLORS[:default]) + 338 | (bold ? TEXT_COLORS[:bold] : "") 339 | disable_ansi = (bold ? DISABLE_TEXT_STYLE[:bold] : "") + 340 | DISABLE_TEXT_STYLE.fetch(color, TEXT_COLORS[:default]) 341 | first = true 342 | StringIO.open do |sio| 343 | str.each_line do |line| 344 | sio.puts unless first 345 | first = false 346 | continue if line.empty? 347 | sio.print(enable_ansi, line, disable_ansi) 348 | end 349 | sio.close 350 | out.print(sio.string) 351 | end 352 | end 353 | 354 | def print_color(out, color, *args) 355 | color = COLOR_DECODE[color] 356 | print_styled(out, *args, color: color) 357 | end 358 | 359 | def color?(out) 360 | (out && out.tty?) || false 361 | end 362 | end 363 | 364 | module BorderPrinter 365 | include StyledPrinter 366 | 367 | def print_border_top(out, padding, length, border=:solid, color: :light_black) 368 | return if border == :none 369 | b = BORDER_MAP[border] 370 | print_styled(out, padding, b[:tl], b[:t] * length, b[:tr], color: color) 371 | end 372 | 373 | def print_border_bottom(out, padding, length, border=:solid, color: :light_black) 374 | return if border == :none 375 | b = BORDER_MAP[border] 376 | print_styled(out, padding, b[:bl], b[:b] * length, b[:br], color: color) 377 | end 378 | end 379 | 380 | class Renderer 381 | include BorderPrinter 382 | 383 | def self.render(out, plot) 384 | new(plot).render(out) 385 | end 386 | 387 | def initialize(plot) 388 | @plot = plot 389 | @out = nil 390 | end 391 | 392 | attr_reader :plot 393 | attr_reader :out 394 | 395 | def render(out) 396 | @out = out 397 | init_render 398 | 399 | render_top 400 | render_rows 401 | render_bottom 402 | end 403 | 404 | private 405 | 406 | def render_top 407 | # plot the title and the top border 408 | print_title(@border_padding, plot.title, p_width: @border_length, color: :bold) 409 | puts if plot.title_given? 410 | 411 | if plot.show_labels? 412 | topleft_str = plot.decorations.fetch(:tl, "") 413 | topleft_col = plot.colors_deco.fetch(:tl, :light_black) 414 | topmid_str = plot.decorations.fetch(:t, "") 415 | topmid_col = plot.colors_deco.fetch(:t, :light_black) 416 | topright_str = plot.decorations.fetch(:tr, "") 417 | topright_col = plot.colors_deco.fetch(:tr, :light_black) 418 | 419 | if topleft_str != "" || topright_str != "" || topmid_str != "" 420 | topleft_len = topleft_str.length 421 | topmid_len = topmid_str.length 422 | topright_len = topright_str.length 423 | print_styled(out, @border_padding, topleft_str, color: topleft_col) 424 | cnt = (@border_length / 2.0 - topmid_len / 2.0 - topleft_len).round 425 | pad = cnt > 0 ? " " * cnt : "" 426 | print_styled(out, pad, topmid_str, color: topmid_col) 427 | cnt = @border_length - topright_len - topleft_len - topmid_len + 2 - cnt 428 | pad = cnt > 0 ? " " * cnt : "" 429 | print_styled(out, pad, topright_str, "\n", color: topright_col) 430 | end 431 | end 432 | 433 | print_border_top(out, @border_padding, @border_length, plot.border) 434 | print(" " * @max_len_r, @plot_padding, "\n") 435 | end 436 | 437 | # render all rows 438 | def render_rows 439 | (0 ... plot.n_rows).each {|row| render_row(row) } 440 | end 441 | 442 | def render_row(row) 443 | # Current labels to left and right of the row and their length 444 | left_str = plot.labels_left.fetch(row, "") 445 | left_col = plot.colors_left.fetch(row, :light_black) 446 | right_str = plot.labels_right.fetch(row, "") 447 | right_col = plot.colors_right.fetch(row, :light_black) 448 | left_len = nocolor_string(left_str).length 449 | right_len = nocolor_string(right_str).length 450 | 451 | unless color?(out) 452 | left_str = nocolor_string(left_str) 453 | right_str = nocolor_string(right_str) 454 | end 455 | 456 | # print left annotations 457 | print(" " * plot.margin) 458 | if plot.show_labels? 459 | if row == @y_lab_row 460 | # print ylabel 461 | print_styled(out, plot.ylabel, color: :normal) 462 | print(" " * (@max_len_l - plot.ylabel_length - left_len)) 463 | else 464 | # print padding to fill ylabel length 465 | print(" " * (@max_len_l - left_len)) 466 | end 467 | # print the left annotation 468 | print_styled(out, left_str, color: left_col) 469 | end 470 | 471 | # print left border 472 | print_styled(out, @plot_padding, @b[:l], color: :light_black) 473 | 474 | # print canvas row 475 | plot.print_row(out, row) 476 | 477 | #print right label and padding 478 | print_styled(out, @b[:r], color: :light_black) 479 | if plot.show_labels? 480 | print(@plot_padding) 481 | print_styled(out, right_str, color: right_col) 482 | print(" " * (@max_len_r - right_len)) 483 | end 484 | puts 485 | end 486 | 487 | def render_bottom 488 | # draw bottom border and bottom labels 489 | print_border_bottom(out, @border_padding, @border_length, plot.border) 490 | print(" " * @max_len_r, @plot_padding) 491 | if plot.show_labels? 492 | botleft_str = plot.decorations.fetch(:bl, "") 493 | botleft_col = plot.colors_deco.fetch(:bl, :light_black) 494 | botmid_str = plot.decorations.fetch(:b, "") 495 | botmid_col = plot.colors_deco.fetch(:b, :light_black) 496 | botright_str = plot.decorations.fetch(:br, "") 497 | botright_col = plot.colors_deco.fetch(:br, :light_black) 498 | 499 | if botleft_str != "" || botright_str != "" || botmid_str != "" 500 | puts 501 | botleft_len = botleft_str.length 502 | botmid_len = botmid_str.length 503 | botright_len = botright_str.length 504 | print_styled(out, @border_padding, botleft_str, color: botleft_col) 505 | cnt = (@border_length / 2.0 - botmid_len / 2.0 - botleft_len).round 506 | pad = cnt > 0 ? " " * cnt : "" 507 | print_styled(out, pad, botmid_str, color: botmid_col) 508 | cnt = @border_length - botright_len - botleft_len - botmid_len + 2 - cnt 509 | pad = cnt > 0 ? " " * cnt : "" 510 | print_styled(out, pad, botright_str, color: botright_col) 511 | end 512 | 513 | # abuse the print_title function to print the xlabel. maybe refactor this 514 | puts if plot.xlabel_given? 515 | print_title(@border_padding, plot.xlabel, p_width: @border_length) 516 | end 517 | end 518 | 519 | def init_render 520 | @b = BORDER_MAP[plot.border] 521 | @border_length = plot.n_columns 522 | 523 | # get length of largest strings to the left and right 524 | @max_len_l = plot.show_labels? && !plot.labels_left.empty? ? 525 | plot.labels_left.each_value.map {|l| nocolor_string(l).length }.max : 526 | 0 527 | @max_len_r = plot.show_labels? && !plot.labels_right.empty? ? 528 | plot.labels_right.each_value.map {|l| nocolor_string(l).length }.max : 529 | 0 530 | if plot.show_labels? && plot.ylabel_given? 531 | @max_len_l += plot.ylabel_length + 1 532 | end 533 | 534 | # offset where the plot (incl border) begins 535 | @plot_offset = @max_len_l + plot.margin + plot.padding 536 | 537 | # padding-string from left to border 538 | @plot_padding = " " * plot.padding 539 | 540 | # padding-string between labels and border 541 | @border_padding = " " * @plot_offset 542 | 543 | # compute position of ylabel 544 | @y_lab_row = (plot.n_rows / 2.0).round - 1 545 | end 546 | 547 | def print_title(padding, title, p_width: 0, color: :normal) 548 | return unless title && title != "" 549 | offset = (p_width / 2.0 - title.length / 2.0).round 550 | offset = [offset, 0].max 551 | tpad = " " * offset 552 | print_styled(out, padding, tpad, title, color: color) 553 | end 554 | 555 | def print(*args) 556 | out.print(*args) 557 | end 558 | 559 | def puts(*args) 560 | out.puts(*args) 561 | end 562 | 563 | def nocolor_string(str) 564 | str.to_s.gsub(/\e\[[0-9]+m/, "") 565 | end 566 | end 567 | 568 | class Plot 569 | include StyledPrinter 570 | 571 | DEFAULT_WIDTH = 40 572 | DEFAULT_BORDER = :solid 573 | DEFAULT_MARGIN = 3 574 | DEFAULT_PADDING = 1 575 | 576 | def initialize(title: nil, 577 | xlabel: nil, 578 | ylabel: nil, 579 | border: DEFAULT_BORDER, 580 | margin: DEFAULT_MARGIN, 581 | padding: DEFAULT_PADDING, 582 | labels: true) 583 | @title = title 584 | @xlabel = xlabel 585 | @ylabel = ylabel 586 | @border = border 587 | @margin = check_margin(margin) 588 | @padding = padding 589 | @labels_left = {} 590 | @colors_left = {} 591 | @labels_right = {} 592 | @colors_right = {} 593 | @decorations = {} 594 | @colors_deco = {} 595 | @show_labels = labels 596 | @auto_color = 0 597 | end 598 | 599 | attr_reader :title 600 | attr_reader :xlabel 601 | attr_reader :ylabel 602 | attr_reader :border 603 | attr_reader :margin 604 | attr_reader :padding 605 | attr_reader :labels_left 606 | attr_reader :colors_left 607 | attr_reader :labels_right 608 | attr_reader :colors_right 609 | attr_reader :decorations 610 | attr_reader :colors_deco 611 | 612 | def title_given? 613 | title && title != "" 614 | end 615 | 616 | def xlabel_given? 617 | xlabel && xlabel != "" 618 | end 619 | 620 | def ylabel_given? 621 | ylabel && ylabel != "" 622 | end 623 | 624 | def ylabel_length 625 | (ylabel && ylabel.length) || 0 626 | end 627 | 628 | def show_labels? 629 | @show_labels 630 | end 631 | 632 | def annotate!(loc, value, color: :normal) 633 | case loc 634 | when :l 635 | (0 ... n_rows).each do |row| 636 | if @labels_left.fetch(row, "") == "" 637 | @labels_left[row] = value 638 | @colors_left[row] = color 639 | break 640 | end 641 | end 642 | when :r 643 | (0 ... n_rows).each do |row| 644 | if @labels_right.fetch(row, "") == "" 645 | @labels_right[row] = value 646 | @colors_right[row] = color 647 | break 648 | end 649 | end 650 | when :t, :b, :tl, :tr, :bl, :br 651 | @decorations[loc] = value 652 | @colors_deco[loc] = color 653 | else 654 | raise ArgumentError, 655 | "unknown location to annotate (#{loc.inspect} for :t, :b, :l, :r, :tl, :tr, :bl, or :br)" 656 | end 657 | end 658 | 659 | def annotate_row!(loc, row_index, value, color: :normal) 660 | case loc 661 | when :l 662 | @labels_left[row_index] = value 663 | @colors_left[row_index] = color 664 | when :r 665 | @labels_right[row_index] = value 666 | @colors_right[row_index] = color 667 | else 668 | raise ArgumentError, "unknown location `#{loc}`, try :l or :r instead" 669 | end 670 | end 671 | 672 | def render(out) 673 | Renderer.render(out, self) 674 | end 675 | 676 | COLOR_CYCLE = [ 677 | :green, 678 | :blue, 679 | :red, 680 | :magenta, 681 | :yellow, 682 | :cyan 683 | ].freeze 684 | 685 | def next_color 686 | COLOR_CYCLE[@auto_color] 687 | ensure 688 | @auto_color = (@auto_color + 1) % COLOR_CYCLE.length 689 | end 690 | 691 | def to_s 692 | StringIO.open do |sio| 693 | render(sio) 694 | sio.close 695 | sio.string 696 | end 697 | end 698 | 699 | private def check_margin(margin) 700 | if margin < 0 701 | raise ArgumentError, "margin must be >= 0" 702 | end 703 | margin 704 | end 705 | 706 | private def check_row_index(row_index) 707 | unless 0 <= row_index && row_index < n_rows 708 | raise ArgumentError, "row_index out of bounds" 709 | end 710 | end 711 | end 712 | 713 | class Barplot < Plot 714 | include ValueTransformer 715 | 716 | MIN_WIDTH = 10 717 | DEFAULT_COLOR = :green 718 | DEFAULT_SYMBOL = "■" 719 | 720 | def initialize(bars, width, color, symbol, transform, **kw) 721 | if symbol.length > 1 722 | raise ArgumentError, "symbol must be a single character" 723 | end 724 | @bars = bars 725 | @symbol = symbol 726 | @max_freq, i = find_max(transform_values(transform, bars)) 727 | @max_len = bars[i].to_s.length 728 | @width = [width, max_len + 7, MIN_WIDTH].max 729 | @color = color 730 | @symbol = symbol 731 | @transform = transform 732 | super(**kw) 733 | end 734 | 735 | attr_reader :max_freq 736 | attr_reader :max_len 737 | attr_reader :width 738 | 739 | def n_rows 740 | @bars.length 741 | end 742 | 743 | def n_columns 744 | @width 745 | end 746 | 747 | def add_row!(bars) 748 | @bars.concat(bars) 749 | @max_freq, i = find_max(transform_values(@transform, bars)) 750 | @max_len = @bars[i].to_s.length 751 | end 752 | 753 | def print_row(out, row_index) 754 | check_row_index(row_index) 755 | bar = @bars[row_index] 756 | max_bar_width = [width - 2 - max_len, 1].max 757 | val = transform_values(@transform, bar) 758 | bar_len = max_freq > 0 ? 759 | ([val, 0].max.fdiv(max_freq) * max_bar_width).round : 760 | 0 761 | bar_str = max_freq > 0 ? @symbol * bar_len : "" 762 | bar_lbl = bar.to_s 763 | print_styled(out, bar_str, color: @color) 764 | print_styled(out, " ", bar_lbl, color: :normal) 765 | pan_len = [max_bar_width + 1 + max_len - bar_len - bar_lbl.length, 0].max 766 | pad = " " * pan_len.round 767 | out.print(pad) 768 | end 769 | 770 | private def find_max(values) 771 | i = j = 0 772 | max = values[i] 773 | while j < values.length 774 | if values[j] > max 775 | i, max = j, values[j] 776 | end 777 | j += 1 778 | end 779 | [max, i] 780 | end 781 | end 782 | end 783 | private_constant :MiniUnicodePlot 784 | end 785 | 786 | --------------------------------------------------------------------------------