chartnado 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gemrelease +2 -0
- data/.gitignore +20 -0
- data/.travis.yml +9 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +23 -0
- data/README.md +137 -0
- data/Rakefile +7 -0
- data/chartnado.gemspec +35 -0
- data/lib/assets/javascripts/chartkick-chartnado.js +864 -0
- data/lib/assets/javascripts/jquery.ajax.queue-concurrent.coffee +76 -0
- data/lib/chartnado.rb +39 -0
- data/lib/chartnado/engine.rb +10 -0
- data/lib/chartnado/evaluator.rb +82 -0
- data/lib/chartnado/group_by.rb +92 -0
- data/lib/chartnado/hash.rb +3 -0
- data/lib/chartnado/helper.rb +7 -0
- data/lib/chartnado/helpers/chart_helper.rb +60 -0
- data/lib/chartnado/helpers/series_helper.rb +35 -0
- data/lib/chartnado/renderer.rb +127 -0
- data/lib/chartnado/series.rb +186 -0
- data/lib/chartnado/version.rb +3 -0
- data/spec/controllers/controller_spec.rb +68 -0
- data/spec/dsl_spec.rb +7 -0
- data/spec/helpers/chart_helper_spec.rb +55 -0
- data/spec/helpers/series_helper_spec.rb +33 -0
- data/spec/rails_helper.rb +4 -0
- data/spec/renderer_spec.rb +50 -0
- data/spec/series_spec.rb +138 -0
- data/spec/spec_helper.rb +16 -0
- metadata +250 -0
@@ -0,0 +1,76 @@
|
|
1
|
+
# from https://gist.github.com/dontfidget/1ad9ab33971b64fe6fef
|
2
|
+
# derived from https://gist.github.com/maccman/5790509
|
3
|
+
|
4
|
+
$ = jQuery
|
5
|
+
|
6
|
+
queues = {}
|
7
|
+
|
8
|
+
queue = (name) ->
|
9
|
+
name = 'default' if name is true
|
10
|
+
queues[name] or= {entries: [], running: 0}
|
11
|
+
|
12
|
+
next = (name, done) ->
|
13
|
+
list = queue(name)
|
14
|
+
|
15
|
+
if done
|
16
|
+
queue(name).running--
|
17
|
+
|
18
|
+
unless list.entries.length
|
19
|
+
return
|
20
|
+
|
21
|
+
[options, deferred] = list.entries[0]
|
22
|
+
|
23
|
+
if list.running >= (options.queueMaxConcurrency || 1)
|
24
|
+
return
|
25
|
+
|
26
|
+
list.entries.shift()
|
27
|
+
|
28
|
+
queue(name).running++
|
29
|
+
|
30
|
+
$.ajax(options)
|
31
|
+
.always(-> next(name, true))
|
32
|
+
.done(-> deferred.resolve(arguments...))
|
33
|
+
.fail(-> deferred.reject(arguments...))
|
34
|
+
|
35
|
+
push = (name, options) ->
|
36
|
+
list = queue(name)
|
37
|
+
deferred = $.Deferred()
|
38
|
+
|
39
|
+
while options && list.entries.length >= options.queueMaxDepth
|
40
|
+
[overflowOptions, overflowDeferred] = list.entries.shift()
|
41
|
+
overflowDeferred.reject null, "queue overflow"
|
42
|
+
|
43
|
+
list.entries.push([options, deferred])
|
44
|
+
|
45
|
+
next(name)
|
46
|
+
deferred.promise()
|
47
|
+
|
48
|
+
remove = (name, options) ->
|
49
|
+
list = queue(name)
|
50
|
+
|
51
|
+
for [value, _], i in list.entries when value is options
|
52
|
+
list.entries.splice(i, 1)
|
53
|
+
break
|
54
|
+
|
55
|
+
$.ajaxTransport '+*', (options) ->
|
56
|
+
if options.queue
|
57
|
+
queuedOptions = $.extend({}, options)
|
58
|
+
queuedOptions.queue = false
|
59
|
+
queuedOptions.processData = false
|
60
|
+
|
61
|
+
send: (headers, complete) ->
|
62
|
+
push(options.queue, queuedOptions)
|
63
|
+
.done (data, textStatus, jqXHR) ->
|
64
|
+
complete(jqXHR.status,
|
65
|
+
jqXHR.statusText,
|
66
|
+
text: jqXHR.responseText,
|
67
|
+
jqXHR.getAllResponseHeaders())
|
68
|
+
|
69
|
+
.fail (jqXHR, textStatus, errorThrown) ->
|
70
|
+
complete(jqXHR.status,
|
71
|
+
jqXHR.statusText,
|
72
|
+
text: jqXHR.responseText,
|
73
|
+
jqXHR.getAllResponseHeaders())
|
74
|
+
|
75
|
+
abort: ->
|
76
|
+
remove(options.queue, queuedOptions)
|
data/lib/chartnado.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'chartnado/version'
|
2
|
+
require 'chartnado/series'
|
3
|
+
require 'chartnado/group_by'
|
4
|
+
require 'chartnado/evaluator'
|
5
|
+
require 'chartnado/helpers/chart_helper'
|
6
|
+
require 'chartnado/helpers/series_helper'
|
7
|
+
require 'chartnado/hash'
|
8
|
+
require 'chartkick/remote'
|
9
|
+
require 'chartnado/engine' if defined?(Rails)
|
10
|
+
|
11
|
+
module Chartnado
|
12
|
+
extend ActiveSupport::Concern
|
13
|
+
attr_accessor :chartnado_options
|
14
|
+
|
15
|
+
included do
|
16
|
+
include Chartkick::Remote
|
17
|
+
|
18
|
+
helper Chartnado::Helpers::Chart
|
19
|
+
end
|
20
|
+
|
21
|
+
module ClassMethods
|
22
|
+
def chartnado_wrapper(wrapper_symbol = nil, **options, &block)
|
23
|
+
unless block
|
24
|
+
helper_method wrapper_symbol
|
25
|
+
block = -> (*args, **options) do
|
26
|
+
render_block = args.pop
|
27
|
+
send(wrapper_symbol, *args, **options, &render_block)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
action_filter_options = options.extract!(:only, :except)
|
32
|
+
|
33
|
+
before_filter action_filter_options do
|
34
|
+
self.chartnado_options ||= {}
|
35
|
+
self.chartnado_options[:wrapper_proc] = block
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Chartnado
|
2
|
+
class Evaluator < SimpleDelegator
|
3
|
+
module Operators
|
4
|
+
def self.included(base)
|
5
|
+
%i{+ * - /}.each do |method|
|
6
|
+
define_method method do |*args, &block|
|
7
|
+
if Thread.current[:in_chartnado_block]
|
8
|
+
OperatorEvaluator.new(self).send(method, *args)
|
9
|
+
elsif defined?(super)
|
10
|
+
super(*args, &block)
|
11
|
+
else
|
12
|
+
raise NoMethodError, "#{method} is not defined"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def coerce(other)
|
18
|
+
return super unless Thread.current[:in_chartnado_block]
|
19
|
+
if other.is_a?(Numeric)
|
20
|
+
[self, other]
|
21
|
+
else
|
22
|
+
super
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class OperatorEvaluator
|
29
|
+
include Series
|
30
|
+
extend Forwardable
|
31
|
+
|
32
|
+
def initialize(object)
|
33
|
+
@object = object
|
34
|
+
end
|
35
|
+
|
36
|
+
def *(value)
|
37
|
+
without_operators do
|
38
|
+
series_product(@object, value)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def +(value)
|
43
|
+
without_operators do
|
44
|
+
series_sum(value, @object)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def -(value)
|
49
|
+
self + -1.0 * value
|
50
|
+
end
|
51
|
+
|
52
|
+
def /(value)
|
53
|
+
without_operators do
|
54
|
+
series_ratio(@object, value)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def_delegator Chartnado::Evaluator, :without_operators
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.without_operators
|
62
|
+
in_chartnado_block = Thread.current[:in_chartnado_block]
|
63
|
+
Thread.current[:in_chartnado_block] = nil
|
64
|
+
yield
|
65
|
+
ensure
|
66
|
+
Thread.current[:in_chartnado_block] = in_chartnado_block
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.with_operators(&block)
|
70
|
+
original_value = Thread.current[:in_chartnado_block]
|
71
|
+
binding = eval 'self', block.binding
|
72
|
+
Thread.current[:in_chartnado_block] = true
|
73
|
+
Evaluator.new(binding).instance_eval(&block)
|
74
|
+
ensure
|
75
|
+
Thread.current[:in_chartnado_block] = original_value
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.with_chartnado_dsl(&block)
|
80
|
+
Evaluator.with_operators(&block)
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require 'chartnado/series'
|
2
|
+
|
3
|
+
module Chartnado::GroupBy
|
4
|
+
include Chartnado::Series
|
5
|
+
|
6
|
+
# @api public
|
7
|
+
# @example
|
8
|
+
# group_by('tasks.user_id', Task.all) { count('DISTINCT project_id') }
|
9
|
+
#
|
10
|
+
# @return [Multiple-Series]
|
11
|
+
def group_by(group_name, scope, label_block = nil, &eval_block)
|
12
|
+
group_values = [group_name] + scope.group_values
|
13
|
+
series = scope.except(:group).group(group_values).
|
14
|
+
instance_eval(&eval_block)
|
15
|
+
|
16
|
+
if label_block
|
17
|
+
update_key_from_block = lambda do |(key, data)|
|
18
|
+
if key.is_a?(Array)
|
19
|
+
group_key = key.first
|
20
|
+
sub_key = key[1..-1]
|
21
|
+
sub_key = sub_key.first if sub_key.length == 1
|
22
|
+
data = {sub_key => data}
|
23
|
+
else
|
24
|
+
group_key = key
|
25
|
+
end
|
26
|
+
(new_key, data) = label_block.call(group_key, data)
|
27
|
+
if key.is_a?(Array)
|
28
|
+
{[new_key, *Array.wrap(sub_key)] => data.values.first}
|
29
|
+
else
|
30
|
+
{new_key => data}
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
if series.length > 0
|
35
|
+
series_sum *series.map(&update_key_from_block)
|
36
|
+
else
|
37
|
+
{}
|
38
|
+
end
|
39
|
+
else
|
40
|
+
series
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
# @api public
|
46
|
+
# @example
|
47
|
+
# mean([0,1]) => {0.5}
|
48
|
+
#
|
49
|
+
# @return Value
|
50
|
+
def mean(array)
|
51
|
+
array.reduce(:+) / array.length
|
52
|
+
end
|
53
|
+
|
54
|
+
# @api public
|
55
|
+
# @return Value
|
56
|
+
def harmonic_mean(array)
|
57
|
+
array = array.reject(&:zero?)
|
58
|
+
return nil unless array.present?
|
59
|
+
array.size / (array.reduce(0) { |mean, value| mean + 1.0 / value })
|
60
|
+
end
|
61
|
+
|
62
|
+
# @api public
|
63
|
+
# @return Value
|
64
|
+
def geometric_mean(array)
|
65
|
+
array.reduce(:*) ** (1.0 / array.length)
|
66
|
+
rescue Math::DomainError
|
67
|
+
nil
|
68
|
+
end
|
69
|
+
|
70
|
+
# @api public
|
71
|
+
# @return Value
|
72
|
+
def grouped_median(series)
|
73
|
+
series.group_by(&:first).reduce({}) do |hash, (key, values)|
|
74
|
+
hash[key] = median(values.map(&:second))
|
75
|
+
hash
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# @api public
|
80
|
+
# @return Value
|
81
|
+
def grouped_mean_and_median(series)
|
82
|
+
series.group_by(&:first).reduce({}) do |hash, (key, values)|
|
83
|
+
values = values.map(&:second).compact
|
84
|
+
next hash unless values.present?
|
85
|
+
hash[['median', key]] = median(values)
|
86
|
+
hash[['mean', key]] = mean(values)
|
87
|
+
hash[['geometric', key]] = geometric_mean(values)
|
88
|
+
hash[['harmonic', key]] = harmonic_mean(values)
|
89
|
+
hash
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'chartnado/renderer'
|
2
|
+
require 'chartkick'
|
3
|
+
require 'chartkick/remote/helper'
|
4
|
+
|
5
|
+
# These helpers can be included in your view using `helper Chartnado::Helpers::Chart`.
|
6
|
+
# They override the default chartkick chart rendering methods with ones that support chartnado DSL.
|
7
|
+
module Chartnado::Helpers
|
8
|
+
module Chart
|
9
|
+
include Chartkick::Helper
|
10
|
+
include Chartkick::Remote::Helper
|
11
|
+
|
12
|
+
def stacked_area_chart(*args, **options, &block)
|
13
|
+
Chartnado::Renderer.new(self, block) do |chartkick_options, json_options, data_block|
|
14
|
+
new_options = chartkick_options.reverse_merge(
|
15
|
+
stacked: true,
|
16
|
+
library: {
|
17
|
+
focusTarget: 'category',
|
18
|
+
series: {
|
19
|
+
0 => {
|
20
|
+
lineWidth: 0,
|
21
|
+
pointSize: 0,
|
22
|
+
visibleInLegend: false
|
23
|
+
}
|
24
|
+
}
|
25
|
+
}
|
26
|
+
)
|
27
|
+
area_chart_without_chartnado(**new_options) do
|
28
|
+
data_block.call(json_options.reverse_merge(show_total: true, reverse_sort: true))
|
29
|
+
end
|
30
|
+
end.render(*args, **options)
|
31
|
+
end
|
32
|
+
|
33
|
+
def line_chart_with_chartnado(*args, **options, &block)
|
34
|
+
Chartnado::Renderer.new(self, block) do |chartkick_options, json_options, data_block|
|
35
|
+
new_options = chartkick_options.reverse_merge(
|
36
|
+
library: {
|
37
|
+
curveType: "none",
|
38
|
+
pointSize: 2,
|
39
|
+
focusTarget: 'category'
|
40
|
+
})
|
41
|
+
line_chart_without_chartnado(**new_options) do
|
42
|
+
data_block.call(**json_options)
|
43
|
+
end
|
44
|
+
end.render(*args, **options)
|
45
|
+
end
|
46
|
+
|
47
|
+
alias_method_chain :line_chart, :chartnado
|
48
|
+
|
49
|
+
%i{geo_chart pie_chart column_chart bar_chart area_chart}.each do |chart_type|
|
50
|
+
define_method(:"#{chart_type}_with_chartnado") do |*args, **options, &block|
|
51
|
+
Chartnado::Renderer.new(self, block) do |chartkick_options, json_options, data_block|
|
52
|
+
send(:"#{chart_type}_without_chartnado", **chartkick_options) do
|
53
|
+
data_block.call(**json_options)
|
54
|
+
end
|
55
|
+
end.render(*args, **options)
|
56
|
+
end
|
57
|
+
alias_method_chain chart_type, :chartnado
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Chartnado::Helpers
|
2
|
+
module Series
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
module ClassMethods
|
6
|
+
def define_series(name, &block)
|
7
|
+
define_method(name) do
|
8
|
+
Chartnado.with_chartnado_dsl do
|
9
|
+
self.instance_exec(&block)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
memoized_name = :"@_memoized_#{name}"
|
14
|
+
define_method(:"#{name}_with_memoized") do |suffix: nil|
|
15
|
+
current_value = instance_variable_get(memoized_name)
|
16
|
+
if !current_value
|
17
|
+
series = send(:"#{name}_without_memoized")
|
18
|
+
instance_variable_set(memoized_name, series)
|
19
|
+
else
|
20
|
+
series = current_value
|
21
|
+
end
|
22
|
+
series = add_suffix_to_series_name(series, suffix) if suffix
|
23
|
+
series
|
24
|
+
end
|
25
|
+
alias_method_chain name, :memoized
|
26
|
+
end
|
27
|
+
|
28
|
+
def define_multiple_series(series)
|
29
|
+
series.each do |name, proc|
|
30
|
+
define_series(name, &proc)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
class Chartnado::Renderer
|
2
|
+
include Chartnado::Series
|
3
|
+
|
4
|
+
attr_accessor :context, :data_block, :render_block
|
5
|
+
|
6
|
+
def initialize(context, data_block, &render_block)
|
7
|
+
@context = context
|
8
|
+
@data_block = data_block
|
9
|
+
@render_block = render_block
|
10
|
+
end
|
11
|
+
|
12
|
+
delegate :controller, to: :context
|
13
|
+
|
14
|
+
def render(*args, **options)
|
15
|
+
json_options = {}
|
16
|
+
chartkick_options = options.dup
|
17
|
+
|
18
|
+
if args.length > 1
|
19
|
+
if args.last.is_a?(Hash)
|
20
|
+
json_options = chartkick_options
|
21
|
+
chartkick_options = args[1].dup
|
22
|
+
end
|
23
|
+
args.select { |arg| arg.is_a?(Symbol) }.each do |key|
|
24
|
+
json_options[key] = true unless json_options.has_key?(key)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
if json_options[:percentage]
|
29
|
+
chartkick_options.reverse_merge!(max: 100.0)
|
30
|
+
end
|
31
|
+
|
32
|
+
options = controller.chartnado_options if controller.respond_to?(:chartnado_options)
|
33
|
+
options ||= {}
|
34
|
+
|
35
|
+
chart_json_proc = -> (*args) {
|
36
|
+
chart_json(Chartnado.with_chartnado_dsl(&data_block), *args)
|
37
|
+
}
|
38
|
+
renderer = -> {
|
39
|
+
context.instance_exec(chartkick_options, json_options, chart_json_proc, &render_block)
|
40
|
+
}
|
41
|
+
|
42
|
+
if options[:wrapper_proc]
|
43
|
+
context.instance_exec(*args, renderer, **options, &options[:wrapper_proc])
|
44
|
+
else
|
45
|
+
renderer.call
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def chart_json(series, show_total: false, reverse_sort: false, percentage: false)
|
50
|
+
series = series_product(100.0, series) if percentage
|
51
|
+
if series.is_a?(Hash)
|
52
|
+
if (key = series.keys.first) and key.is_a?(Array) and key.size == 2
|
53
|
+
totals = Hash.new(0.0)
|
54
|
+
new_series = series.group_by{|k, v| k[0] }.sort_by { |k| k.to_s }
|
55
|
+
new_series = new_series.reverse if reverse_sort
|
56
|
+
|
57
|
+
new_series = new_series.map do |name, data|
|
58
|
+
{
|
59
|
+
name: name,
|
60
|
+
data: data.map do |k, v|
|
61
|
+
totals[k[1]] += v if show_total
|
62
|
+
[k[1], v]
|
63
|
+
end
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
if show_total
|
68
|
+
[{name: 'Total',
|
69
|
+
data: totals.map {|k,v| [k, 0] },
|
70
|
+
tooltip: totals.map {|k,v| [k, v] }
|
71
|
+
}] + new_series
|
72
|
+
else
|
73
|
+
new_series
|
74
|
+
end
|
75
|
+
else
|
76
|
+
new_series = series.sort_by { |key| key.to_s }
|
77
|
+
new_series = new_series.reverse if reverse_sort
|
78
|
+
|
79
|
+
if show_total
|
80
|
+
new_series << ['Total', new_series.map(&:second).reduce(0, :+)]
|
81
|
+
else
|
82
|
+
new_series
|
83
|
+
end
|
84
|
+
end
|
85
|
+
elsif series.is_a?(Array) && series.first.is_a?(Array)
|
86
|
+
if series.first.second.respond_to?(:map)
|
87
|
+
totals = Hash.new(0.0)
|
88
|
+
new_series = series.sort_by { |item| item.first.to_s }
|
89
|
+
new_series = new_series.reverse if reverse_sort
|
90
|
+
|
91
|
+
new_series = new_series.map do |name, data|
|
92
|
+
{
|
93
|
+
name: name,
|
94
|
+
data: data.map do |k, v|
|
95
|
+
totals[k] += v if show_total
|
96
|
+
[k, v]
|
97
|
+
end
|
98
|
+
}
|
99
|
+
end
|
100
|
+
|
101
|
+
if show_total
|
102
|
+
[{name: 'Total',
|
103
|
+
data: totals.map {|k,v| [k, 0] },
|
104
|
+
tooltip: totals.map {|k,v| [k, v] }
|
105
|
+
}] + new_series
|
106
|
+
else
|
107
|
+
new_series
|
108
|
+
end
|
109
|
+
else
|
110
|
+
new_series = series.sort_by { |item| item.first.to_s }
|
111
|
+
new_series = new_series.reverse if reverse_sort
|
112
|
+
|
113
|
+
if show_total
|
114
|
+
new_series << ['Total', new_series.map(&:second).reduce(0, :+)]
|
115
|
+
else
|
116
|
+
new_series
|
117
|
+
end
|
118
|
+
end
|
119
|
+
else
|
120
|
+
if series.respond_to?(:map)
|
121
|
+
series
|
122
|
+
else
|
123
|
+
[['Total', series]]
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|