sparkplug 2.0.0
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.
- data/.document +5 -0
- data/.gitignore +7 -0
- data/LICENSE +20 -0
- data/README.rdoc +35 -0
- data/Rakefile +56 -0
- data/VERSION +1 -0
- data/demos/simple/public/temps/portland/2007.csv +1 -0
- data/demos/simple/sparkplug_demo.rb +29 -0
- data/demos/simple/views/readme.erb +19 -0
- data/lib/spark_pr.rb +231 -0
- data/lib/sparkplug.rb +48 -0
- data/lib/sparkplug/cachers/abstract.rb +57 -0
- data/lib/sparkplug/cachers/filesystem.rb +49 -0
- data/lib/sparkplug/cachers/memory.rb +42 -0
- data/lib/sparkplug/handlers/abstract_data.rb +38 -0
- data/lib/sparkplug/handlers/csv_data.rb +31 -0
- data/lib/sparkplug/handlers/stubbed_data.rb +36 -0
- data/sparkplug.gemspec +54 -0
- data/test/sparkplug_test.rb +94 -0
- metadata +74 -0
data/.document
ADDED
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 rick
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
= sparkplug
|
2
|
+
|
3
|
+
Dynamically generates sparkline graphs from a set of numbers. This is done
|
4
|
+
primarily through Handlers and Cachers. Handlers know how to fetch the data,
|
5
|
+
and Cachers know how to cache the generated PNG sparkline for future requests.
|
6
|
+
|
7
|
+
pub_dir = File.expand_path(File.join(File.dirname(__FILE__), 'public'))
|
8
|
+
data_dir = File.join(pub_dir, 'temps')
|
9
|
+
cache_dir = File.join(pub_dir, 'sparks')
|
10
|
+
|
11
|
+
use Sparkplug, :prefix => 'sparks',
|
12
|
+
:handler => Sparkplug::Handlers::CsvData.new(data_dir),
|
13
|
+
:cacher => Sparkplug::Cachers::Filesystem.new(cache_dir)
|
14
|
+
|
15
|
+
* An incoming request hits your Rack application at "/sparks/foo/stats.csv".
|
16
|
+
* The CSV Handler gets 'foo/stats.csv', and checks for this file in its data
|
17
|
+
directory. It parses the first row of numbers as the set of points to plot.
|
18
|
+
* The Filesystem Cacher checks for a more recent cache. Failing that, it
|
19
|
+
generates the PNG graph and writes it to the cache directory.
|
20
|
+
|
21
|
+
Mix and match your own handlers and cachers with your friends!
|
22
|
+
|
23
|
+
== Demo
|
24
|
+
|
25
|
+
See demo/sparkplug_demo.rb or http://rack-sparklines.heroku.com/
|
26
|
+
|
27
|
+
== Codes
|
28
|
+
|
29
|
+
gem install sparkplug
|
30
|
+
|
31
|
+
http://github.com/technoweenie/sparkplug
|
32
|
+
|
33
|
+
== Copyright
|
34
|
+
|
35
|
+
Copyright (c) 2009 rick. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "sparkplug"
|
8
|
+
gem.summary = %Q{Rack module that dynamically generates sparkline graphs from a set of numbers.}
|
9
|
+
gem.email = "technoweenie@gmail.com"
|
10
|
+
gem.homepage = "http://github.com/technoweenie/sparkplug"
|
11
|
+
gem.authors = ["rick"]
|
12
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
13
|
+
end
|
14
|
+
|
15
|
+
rescue LoadError
|
16
|
+
puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
|
17
|
+
end
|
18
|
+
|
19
|
+
require 'rake/testtask'
|
20
|
+
Rake::TestTask.new(:test) do |test|
|
21
|
+
test.libs << 'lib' << 'test'
|
22
|
+
test.pattern = 'test/**/*_test.rb'
|
23
|
+
test.verbose = true
|
24
|
+
end
|
25
|
+
|
26
|
+
begin
|
27
|
+
require 'rcov/rcovtask'
|
28
|
+
Rcov::RcovTask.new do |test|
|
29
|
+
test.libs << 'test'
|
30
|
+
test.pattern = 'test/**/*_test.rb'
|
31
|
+
test.verbose = true
|
32
|
+
end
|
33
|
+
rescue LoadError
|
34
|
+
task :rcov do
|
35
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
task :default => :test
|
41
|
+
|
42
|
+
require 'rake/rdoctask'
|
43
|
+
Rake::RDocTask.new do |rdoc|
|
44
|
+
if File.exist?('VERSION.yml')
|
45
|
+
config = YAML.load(File.read('VERSION.yml'))
|
46
|
+
version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
|
47
|
+
else
|
48
|
+
version = ""
|
49
|
+
end
|
50
|
+
|
51
|
+
rdoc.rdoc_dir = 'rdoc'
|
52
|
+
rdoc.title = "sparkplug #{version}"
|
53
|
+
rdoc.rdoc_files.include('README*')
|
54
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
55
|
+
end
|
56
|
+
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.0.0
|
@@ -0,0 +1 @@
|
|
1
|
+
35.7,47.2,50,42.4,38.8,43.9,46.9,46,46.9,39.6,34.3,28.9,27.5,27.1,28.5,28.5,30.8,34.9,35.6,39.3,38.1,42,43,39.2,37.2,40,39.5,38,37,37.3,40,34.7,36.4,35.1,42
|
@@ -0,0 +1,29 @@
|
|
1
|
+
$LOAD_PATH << File.join(File.dirname(__FILE__), '..', '..', 'lib')
|
2
|
+
require 'rubygems'
|
3
|
+
require 'sinatra'
|
4
|
+
|
5
|
+
require 'sparkplug'
|
6
|
+
require 'sparkplug/handlers/csv_data'
|
7
|
+
require 'sparkplug/cachers/filesystem'
|
8
|
+
|
9
|
+
pub_dir = File.expand_path(File.join(File.dirname(__FILE__), 'public'))
|
10
|
+
use Sparkplug, :prefix => 'sparks',
|
11
|
+
:handler => Sparkplug::Handlers::CsvData.new(File.join(pub_dir, 'temps')),
|
12
|
+
:cacher => Sparkplug::Cachers::Filesystem.new(File.join(pub_dir, 'sparks'))
|
13
|
+
|
14
|
+
get '/' do
|
15
|
+
@body = $readme
|
16
|
+
erb :readme
|
17
|
+
end
|
18
|
+
|
19
|
+
def simple_format(text)
|
20
|
+
start_tag = "<p>"
|
21
|
+
text = text.to_s.dup
|
22
|
+
text.gsub!(/\r\n?/, "\n") # \r\n and \r -> \n
|
23
|
+
text.gsub!(/\n\n+/, "</p>\n\n#{start_tag}") # 2+ newline -> paragraph
|
24
|
+
text.gsub!(/([^\n]\n)(?=[^\n])/, '\1<br />') # 1 newline -> br
|
25
|
+
text.insert 0, start_tag
|
26
|
+
text << "</p>"
|
27
|
+
end
|
28
|
+
|
29
|
+
$readme = simple_format IO.read(File.join(File.dirname(__FILE__), '..', '..', 'README.rdoc'))
|
@@ -0,0 +1,19 @@
|
|
1
|
+
<html>
|
2
|
+
<head>
|
3
|
+
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
|
4
|
+
<title>Sparkplug - Ruby Rack module for generating sparkline graphs on the fly</title>
|
5
|
+
<style type="text/css" media="screen">
|
6
|
+
h1, div, p {
|
7
|
+
font-family: verdana;
|
8
|
+
}
|
9
|
+
</style>
|
10
|
+
</head>
|
11
|
+
<body>
|
12
|
+
<h1>Sparkplug</h1>
|
13
|
+
<div>
|
14
|
+
<img src="/sparks/portland/2007.csv.png" />
|
15
|
+
</div>
|
16
|
+
<p>(if you can see this, the rack module works!)</p>
|
17
|
+
<%= @body %>
|
18
|
+
</body>
|
19
|
+
</html>
|
data/lib/spark_pr.rb
ADDED
@@ -0,0 +1,231 @@
|
|
1
|
+
# pure ruby sparklines module, generates PNG or ASCII
|
2
|
+
# contact thomas@fesch.at for questions
|
3
|
+
#
|
4
|
+
# strives to be somewhat compatible with sparklines lib by
|
5
|
+
# {Dan Nugent}[mailto:nugend@gmail.com] and {Geoffrey Grosenbach}[mailto:boss@topfunky.com]
|
6
|
+
#
|
7
|
+
# png creation based on http://www.whytheluckystiff.net/bumpspark/
|
8
|
+
|
9
|
+
class SparkCanvas
|
10
|
+
require 'zlib'
|
11
|
+
|
12
|
+
attr_accessor :color
|
13
|
+
attr_reader :width, :height
|
14
|
+
|
15
|
+
def initialize(width,height)
|
16
|
+
@canvas = []
|
17
|
+
@height = height
|
18
|
+
@width = width
|
19
|
+
height.times{ @canvas << [[0xFF,0xFF,0xFF]]*width }
|
20
|
+
@color = [0,0,0,0xFF] #RGBA
|
21
|
+
end
|
22
|
+
|
23
|
+
# alpha blends two colors, using the alpha given by c2
|
24
|
+
def blend(c1, c2)
|
25
|
+
(0..2).map{ |i| (c1[i]*(0xFF-c2[3]) + c2[i]*c2[3]) >> 8 }
|
26
|
+
end
|
27
|
+
|
28
|
+
# calculate a new alpha given a 0-0xFF intensity
|
29
|
+
def intensity(c,i)
|
30
|
+
[c[0],c[1],c[2],(c[3]*i) >> 8]
|
31
|
+
end
|
32
|
+
|
33
|
+
# calculate perceptive grayscale value
|
34
|
+
def grayscale(c)
|
35
|
+
(c[0]*0.3 + c[1]*0.59 + c[2]*0.11).to_i
|
36
|
+
end
|
37
|
+
|
38
|
+
def point(x,y,color = nil)
|
39
|
+
return if x<0 or y<0 or x>@width-1 or y>@height-1
|
40
|
+
@canvas[y][x] = blend(@canvas[y][x], color || @color)
|
41
|
+
end
|
42
|
+
|
43
|
+
def rectangle(x0, y0, x1, y1)
|
44
|
+
x0, y0, x1, y1 = x0.to_i, y0.to_i, x1.to_i, y1.to_i
|
45
|
+
x0, x1 = x1, x0 if x0 > x1
|
46
|
+
y0, y1 = y1, y0 if y0 > y1
|
47
|
+
x0.upto(x1) { |x| y0.upto(y1) { |y| point x, y } }
|
48
|
+
end
|
49
|
+
|
50
|
+
# draw an antialiased line
|
51
|
+
# google for "wu antialiasing"
|
52
|
+
def line(x0, y0, x1, y1)
|
53
|
+
# clean params
|
54
|
+
x0, y0, x1, y1 = x0.to_i, y0.to_i, x1.to_i, y1.to_i
|
55
|
+
y0, y1, x0, x1 = y1, y0, x1, x0 if y0>y1
|
56
|
+
sx = (dx = x1-x0) < 0 ? -1 : 1 ; dx *= sx ; dy = y1-y0
|
57
|
+
|
58
|
+
# special cases
|
59
|
+
x0.step(x1,sx) { |x| point x, y0 } and return if dy.zero?
|
60
|
+
y0.upto(y1) { |y| point x0, y } and return if dx.zero?
|
61
|
+
x0.step(x1,sx) { |x| point x, y0; y0 += 1 } and return if dx==dy
|
62
|
+
|
63
|
+
# main loops
|
64
|
+
point x0, y0
|
65
|
+
|
66
|
+
e_acc = 0
|
67
|
+
if dy > dx
|
68
|
+
e = (dx << 16) / dy
|
69
|
+
y0.upto(y1-1) do
|
70
|
+
e_acc_temp, e_acc = e_acc, (e_acc + e) & 0xFFFF
|
71
|
+
x0 += sx if (e_acc <= e_acc_temp)
|
72
|
+
point x0, (y0 += 1), intensity(@color,(w=0xFF-(e_acc >> 8)))
|
73
|
+
point x0+sx, y0, intensity(@color,(0xFF-w))
|
74
|
+
end
|
75
|
+
point x1, y1
|
76
|
+
return
|
77
|
+
end
|
78
|
+
|
79
|
+
e = (dy << 16) / dx
|
80
|
+
x0.step(x1-sx,sx) do
|
81
|
+
e_acc_temp, e_acc = e_acc, (e_acc + e) & 0xFFFF
|
82
|
+
y0 += 1 if (e_acc <= e_acc_temp)
|
83
|
+
point (x0 += sx), y0, intensity(@color,(w=0xFF-(e_acc >> 8)))
|
84
|
+
point x0, y0+1, intensity(@color,(0xFF-w))
|
85
|
+
end
|
86
|
+
point x1, y1
|
87
|
+
end
|
88
|
+
|
89
|
+
def polyline(arr)
|
90
|
+
(0...arr.size-1).each{ |i| line(arr[i][0], arr[i][1], arr[i+1][0], arr[i+1][1]) }
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_png
|
94
|
+
header = [137, 80, 78, 71, 13, 10, 26, 10].pack("C*")
|
95
|
+
raw_data = @canvas.map { |row| [0] + row }.flatten.pack("C*")
|
96
|
+
ihdr_data = [@canvas.first.length,@canvas.length,8,2,0,0,0].pack("NNCCCCC")
|
97
|
+
|
98
|
+
header +
|
99
|
+
build_png_chunk("IHDR", ihdr_data) +
|
100
|
+
build_png_chunk("tRNS", ([ 0xFF ]*6).pack("C6")) +
|
101
|
+
build_png_chunk("IDAT", Zlib::Deflate.deflate(raw_data)) +
|
102
|
+
build_png_chunk("IEND", "")
|
103
|
+
end
|
104
|
+
|
105
|
+
def build_png_chunk(type,data)
|
106
|
+
to_check = type + data
|
107
|
+
[data.length].pack("N") + to_check + [Zlib.crc32(to_check)].pack("N")
|
108
|
+
end
|
109
|
+
|
110
|
+
def to_ascii
|
111
|
+
chr = %w(M O # + ; - .) << ' '
|
112
|
+
@canvas.map{ |r| r.map { |pt| chr[grayscale(pt) >> 5] }.to_s << "\n" }.to_s
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
116
|
+
|
117
|
+
module Spark
|
118
|
+
# normalize arr to contain values between 0..1 inclusive
|
119
|
+
def Spark.normalize( arr, type = :linear )
|
120
|
+
arr.map!{|v| Math.log(v) } if type == :logarithmic
|
121
|
+
adj, fac = arr.min, arr.max-arr.min
|
122
|
+
arr.map do |v|
|
123
|
+
v = (v-adj).quo(fac) rescue 0
|
124
|
+
v = 0 if v.respond_to?(:nan?) && v.nan?
|
125
|
+
v
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def Spark.process_options( options )
|
130
|
+
o = options.inject({}) do |o, (key, value)|
|
131
|
+
o[key.to_sym] = value ; o
|
132
|
+
end
|
133
|
+
[:height, :width, :step].each do |k|
|
134
|
+
o[k] = o[k].to_i if o.has_key?(k)
|
135
|
+
end
|
136
|
+
[:has_min, :has_max, :has_last].each do |k|
|
137
|
+
o[k] = (o[k] ? true : false) if o.has_key?(k)
|
138
|
+
end
|
139
|
+
o[:normalize] ||= :linear
|
140
|
+
o[:normalize] = o[:normalize].to_sym
|
141
|
+
o
|
142
|
+
end
|
143
|
+
|
144
|
+
def Spark.smooth( results, options = {} )
|
145
|
+
options = self.process_options(options)
|
146
|
+
o = {
|
147
|
+
:step => 2,
|
148
|
+
:height => 14,
|
149
|
+
:has_min => false,
|
150
|
+
:has_max => false
|
151
|
+
}.merge(options)
|
152
|
+
|
153
|
+
o[:width] ||= (results.size-1)*o[:step] + 5
|
154
|
+
|
155
|
+
c = SparkCanvas.new(o[:width], o[:height])
|
156
|
+
|
157
|
+
results = Spark.normalize(results, o[:normalize])
|
158
|
+
fac = c.height-5
|
159
|
+
i = -o[:step]
|
160
|
+
coords = results.map do |r|
|
161
|
+
[(i += o[:step])+2, c.height - 3 - r*fac ]
|
162
|
+
end
|
163
|
+
|
164
|
+
c.color = [0xB0, 0xB0, 0xB0, 0xFF]
|
165
|
+
c.polyline coords
|
166
|
+
|
167
|
+
if o[:has_min]
|
168
|
+
min_pt = coords[results.index(results.min)]
|
169
|
+
c.color = [0x80, 0x80, 0x00, 0x70]
|
170
|
+
c.rectangle(min_pt[0]-2, min_pt[1]-2, min_pt[0]+2, min_pt[1]+2)
|
171
|
+
end
|
172
|
+
|
173
|
+
if o[:has_max]
|
174
|
+
max_pt = coords[results.index(results.max)]
|
175
|
+
c.color = [0x00, 0x80, 0x00, 0x70]
|
176
|
+
c.rectangle(max_pt[0]-2, max_pt[1]-2, max_pt[0]+2, max_pt[1]+2)
|
177
|
+
end
|
178
|
+
|
179
|
+
if o[:has_last]
|
180
|
+
c.color = [0xFF, 0x00, 0x00, 0x70]
|
181
|
+
c.rectangle(coords.last[0]-2, coords.last[1]-2, coords.last[0]+2, coords.last[1]+2)
|
182
|
+
end
|
183
|
+
|
184
|
+
c
|
185
|
+
end
|
186
|
+
|
187
|
+
def Spark.discrete( results, options = {} )
|
188
|
+
options = self.process_options(options)
|
189
|
+
o = {
|
190
|
+
:height => 14,
|
191
|
+
:upper => 0.5,
|
192
|
+
:has_min => false,
|
193
|
+
:has_max => false
|
194
|
+
}.merge(options)
|
195
|
+
|
196
|
+
o[:width] ||= results.size*2-1
|
197
|
+
|
198
|
+
c = SparkCanvas.new(o[:width], o[:height])
|
199
|
+
|
200
|
+
results = Spark.normalize(results, o[:normalize])
|
201
|
+
fac = c.height-4
|
202
|
+
|
203
|
+
i = -2
|
204
|
+
results.each do |r|
|
205
|
+
p = c.height - 4 - r*fac
|
206
|
+
c.color = r < o[:upper] ? [0x66,0x66,0x66,0xFF] : [0xFF,0x00,0x00,0xFF]
|
207
|
+
c.line(i+=2, p, i, p+3)
|
208
|
+
end
|
209
|
+
|
210
|
+
c
|
211
|
+
end
|
212
|
+
|
213
|
+
# convenience method
|
214
|
+
def Spark.plot( results, options = {})
|
215
|
+
options = self.process_options(options)
|
216
|
+
options[:type] ||= 'smooth'
|
217
|
+
self.send(options[:type], results, options).to_png
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
if $0 == __FILE__
|
222
|
+
#to test this:
|
223
|
+
#PNG output
|
224
|
+
File.open( 'test.png', 'wb' ) do |png|
|
225
|
+
png << Spark.plot( [47, 43, 24, 47, 16, 28, 38, 57, 50, 76, 42, 20, 98, 34, 53, 1, 55, 74, 63, 38, 31, 98, 89], :has_min => true, :has_max => true, 'has_last' => 'true', 'height' => '40', :step => 10, :normalize => 'logarithmic' )
|
226
|
+
end
|
227
|
+
|
228
|
+
#ASCII output
|
229
|
+
puts Spark.discrete( [47, 43, 24, 47, 16, 28, 38, 57, 50, 76, 42, 1, 98, 34, 53, 97, 55, 74, 63, 38, 31, 98, 89], :has_min => true, :has_max => true, :height => 14, :step => 5 ).to_ascii
|
230
|
+
puts Spark.smooth( [47, 43, 24, 47, 16, 28, 38, 57, 50, 76, 42, 1, 98, 34, 53, 97, 55, 74, 63, 38, 31, 98, 89], :has_min => true, :has_max => true, :height => 14, :step => 4 ).to_ascii
|
231
|
+
end
|
data/lib/sparkplug.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'spark_pr'
|
2
|
+
|
3
|
+
# Render sparkline graphs dynamically from datapoints in a matching CSV file
|
4
|
+
# (or anything that there is a Handler for).
|
5
|
+
class Sparkplug
|
6
|
+
DEFAULT_SPARK_OPTIONS = {:has_min => true, :has_max => true, :height => 40, :step => 10}
|
7
|
+
|
8
|
+
# Options:
|
9
|
+
# :spark - Hash of sparkline options. See spark_pr.rb
|
10
|
+
# :prefix - URL prefix for handled requests. Setting it to "/sparks"
|
11
|
+
# treats requests like "/sparks/stats.csv" as dynamic sparklines.
|
12
|
+
# :cacher - Cachers know how to store and stream sparkline PNG data.
|
13
|
+
# :handler - Handler instances know how to fetch data and pass them
|
14
|
+
# to the Sparklines library.
|
15
|
+
def initialize(app, options = {})
|
16
|
+
@app, @options = app, options
|
17
|
+
@options[:spark] = DEFAULT_SPARK_OPTIONS.merge(@options[:spark] || {})
|
18
|
+
end
|
19
|
+
|
20
|
+
def call(env)
|
21
|
+
dup._call(env)
|
22
|
+
end
|
23
|
+
|
24
|
+
def _call(env)
|
25
|
+
if env['PATH_INFO'][@options[:prefix]] == @options[:prefix]
|
26
|
+
@data_path = env['PATH_INFO'][@options[:prefix].size+1..-1]
|
27
|
+
@data_path.sub! /\.png$/, ''
|
28
|
+
@png_path = @data_path + ".png"
|
29
|
+
@cacher = @options[:cacher].set(@png_path)
|
30
|
+
@handler = @options[:handler].set(@data_path)
|
31
|
+
if !@handler.exists?
|
32
|
+
return @app.call(env)
|
33
|
+
end
|
34
|
+
if !@handler.already_cached?(@cacher)
|
35
|
+
@handler.fetch do |data|
|
36
|
+
@cacher.save(data, @options[:spark])
|
37
|
+
end
|
38
|
+
end
|
39
|
+
@cacher.serve(self)
|
40
|
+
else
|
41
|
+
@app.call(env)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def each
|
46
|
+
@cacher.stream { |part| yield part }
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'sparkplug'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
class Sparkplug
|
5
|
+
module Cachers
|
6
|
+
# Reads sparkline data from CSV files. Only the first line of numbers are
|
7
|
+
# read. Requests for "/sparks/stats.csv" will pass a data_path of "stats.csv"
|
8
|
+
class Abstract
|
9
|
+
attr_accessor :png_path
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@size, @updated_at = nil
|
13
|
+
end
|
14
|
+
|
15
|
+
# Setting the png_path returns a duplicate of this object that has any
|
16
|
+
# custom instance variables (configuration settings, for example).
|
17
|
+
def set(png_path)
|
18
|
+
cacher = dup
|
19
|
+
cacher.png_path = png_path
|
20
|
+
cacher
|
21
|
+
end
|
22
|
+
|
23
|
+
def size
|
24
|
+
raise NotImplementedError
|
25
|
+
end
|
26
|
+
|
27
|
+
def exists?
|
28
|
+
raise NotImplementedError
|
29
|
+
end
|
30
|
+
|
31
|
+
def updated_at
|
32
|
+
raise NotImplementedError
|
33
|
+
end
|
34
|
+
|
35
|
+
def create_sparklines(data, options)
|
36
|
+
Spark.plot(data, options)
|
37
|
+
end
|
38
|
+
|
39
|
+
def serve(app, headers = {})
|
40
|
+
headers = {
|
41
|
+
"Last-Modified" => updated_at.rfc822,
|
42
|
+
"Content-Type" => "image/png",
|
43
|
+
"Content-Length" => size.to_s
|
44
|
+
}.update(headers)
|
45
|
+
[200, headers, app]
|
46
|
+
end
|
47
|
+
|
48
|
+
def save(data, options)
|
49
|
+
raise NotImplementedError
|
50
|
+
end
|
51
|
+
|
52
|
+
def stream
|
53
|
+
raise NotImplementedError
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'sparkplug/cachers/abstract'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
class Sparkplug
|
5
|
+
module Cachers
|
6
|
+
# Reads sparkline data from CSV files. Only the first line of numbers are
|
7
|
+
# read. Requests for "/sparks/stats.csv" will pass a data_path of "stats.csv"
|
8
|
+
class Filesystem < Abstract
|
9
|
+
attr_accessor :directory
|
10
|
+
|
11
|
+
def initialize(directory)
|
12
|
+
@directory = directory
|
13
|
+
super()
|
14
|
+
end
|
15
|
+
|
16
|
+
def png_path=(s)
|
17
|
+
@cache_file = File.join(@directory, s)
|
18
|
+
@png_path = s
|
19
|
+
end
|
20
|
+
|
21
|
+
def size
|
22
|
+
@size ||= File.size(@cache_file)
|
23
|
+
end
|
24
|
+
|
25
|
+
def exists?
|
26
|
+
File.file?(@cache_file)
|
27
|
+
end
|
28
|
+
|
29
|
+
def updated_at
|
30
|
+
@updated_at ||= File.mtime(@cache_file)
|
31
|
+
end
|
32
|
+
|
33
|
+
def save(data, options)
|
34
|
+
FileUtils.mkdir_p(File.dirname(@cache_file))
|
35
|
+
File.open(@cache_file, 'wb') do |png|
|
36
|
+
png << create_sparklines(data, options)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def stream
|
41
|
+
::File.open(@cache_file, "rb") do |file|
|
42
|
+
while part = file.read(8192)
|
43
|
+
yield part
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'sparkplug/cachers/abstract'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
class Sparkplug
|
5
|
+
module Cachers
|
6
|
+
# Reads sparkline data from CSV files. Only the first line of numbers are
|
7
|
+
# read. Requests for "/sparks/stats.csv" will pass a data_path of "stats.csv"
|
8
|
+
class Memory < Abstract
|
9
|
+
attr_accessor :sparklines, :cache_time
|
10
|
+
|
11
|
+
def initialize(cache_time = 86400)
|
12
|
+
@cache_time = cache_time
|
13
|
+
super()
|
14
|
+
end
|
15
|
+
|
16
|
+
def size
|
17
|
+
@sparklines ? @sparklines.size : 0
|
18
|
+
end
|
19
|
+
|
20
|
+
def exists?
|
21
|
+
@sparklines
|
22
|
+
end
|
23
|
+
|
24
|
+
def updated_at
|
25
|
+
Time.now.utc
|
26
|
+
end
|
27
|
+
|
28
|
+
def save(data, options)
|
29
|
+
@sparklines = create_sparklines(data, options)
|
30
|
+
end
|
31
|
+
|
32
|
+
def stream
|
33
|
+
yield @sparklines
|
34
|
+
end
|
35
|
+
|
36
|
+
def serve(app, headers = {})
|
37
|
+
headers['Cache-Control'] = "public, max-age=#{@cache_time}"
|
38
|
+
super(app, headers)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'sparkplug'
|
2
|
+
|
3
|
+
class Sparkplug
|
4
|
+
module Handlers
|
5
|
+
# Abstract class for retrieving the data and determining whether the cache
|
6
|
+
# needs to be refreshed.
|
7
|
+
class AbstractData
|
8
|
+
attr_accessor :data_path
|
9
|
+
|
10
|
+
# Setting the data_path returns a duplicate of this object that has any
|
11
|
+
# custom instance variables (configuration settings, for example).
|
12
|
+
def set(data_path)
|
13
|
+
data = dup
|
14
|
+
data.data_path = data_path
|
15
|
+
data
|
16
|
+
end
|
17
|
+
|
18
|
+
def already_cached?(cacher)
|
19
|
+
if cache_time = cacher.exists? && cacher.updated_at
|
20
|
+
cache_time > updated_at
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def exists?
|
25
|
+
false
|
26
|
+
end
|
27
|
+
|
28
|
+
def updated_at
|
29
|
+
raise NotImplementedError
|
30
|
+
end
|
31
|
+
|
32
|
+
# Yield an array of numbers for sparkline datapoints.
|
33
|
+
def fetch
|
34
|
+
raise NotImplementedError
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'sparkplug/handlers/abstract_data'
|
2
|
+
|
3
|
+
module Sparkplug::Handlers
|
4
|
+
# Reads sparkline data from CSV files. Only the first line of numbers are
|
5
|
+
# read. Requests for "/sparks/stats.csv" will pass a data_path of "stats.csv"
|
6
|
+
class CsvData < AbstractData
|
7
|
+
attr_accessor :directory
|
8
|
+
|
9
|
+
def initialize(directory)
|
10
|
+
@directory = directory
|
11
|
+
end
|
12
|
+
|
13
|
+
def data_path=(s)
|
14
|
+
@data_path = s ? File.join(@directory, s) : nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def exists?
|
18
|
+
File.exist?(@data_path)
|
19
|
+
end
|
20
|
+
|
21
|
+
def updated_at
|
22
|
+
File.mtime(@data_path)
|
23
|
+
end
|
24
|
+
|
25
|
+
def fetch
|
26
|
+
array_of_nums = IO.read(@data_path).split("\n").first.split(",")
|
27
|
+
array_of_nums.map! { |n| n.to_i }
|
28
|
+
yield array_of_nums
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'sparkplug/handlers/abstract_data'
|
2
|
+
|
3
|
+
module Sparkplug::Handlers
|
4
|
+
# Allows you to stub sparkline data in a global hash. Requests for
|
5
|
+
# "/sparks/stats.csv" will pass a data_path of "stats.csv"
|
6
|
+
class StubbedData < AbstractData
|
7
|
+
# A hash of hashes where the key is the filename. The key points to
|
8
|
+
# a hash with :updated and :contents keys
|
9
|
+
#
|
10
|
+
# StubbedData.datasets['stats.csv'] = {
|
11
|
+
# :updated => Time.utc(2009, 10, 1),
|
12
|
+
# :contents => [1, 2, 3, 4, 5]}
|
13
|
+
attr_accessor :datasets
|
14
|
+
|
15
|
+
def initialize(datasets = {})
|
16
|
+
@datasets = datasets
|
17
|
+
end
|
18
|
+
|
19
|
+
def data_path=(s)
|
20
|
+
@data = @datasets[s]
|
21
|
+
@data_path = s
|
22
|
+
end
|
23
|
+
|
24
|
+
def exists?
|
25
|
+
@data
|
26
|
+
end
|
27
|
+
|
28
|
+
def updated_at
|
29
|
+
@data[:updated]
|
30
|
+
end
|
31
|
+
|
32
|
+
def fetch
|
33
|
+
yield @data[:contents] if @data
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/sparkplug.gemspec
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = %q{sparkplug}
|
5
|
+
s.version = "2.0.0"
|
6
|
+
|
7
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
8
|
+
s.authors = ["rick"]
|
9
|
+
s.date = %q{2009-11-01}
|
10
|
+
s.email = %q{technoweenie@gmail.com}
|
11
|
+
s.extra_rdoc_files = [
|
12
|
+
"LICENSE",
|
13
|
+
"README.rdoc"
|
14
|
+
]
|
15
|
+
s.files = [
|
16
|
+
".document",
|
17
|
+
".gitignore",
|
18
|
+
"LICENSE",
|
19
|
+
"README.rdoc",
|
20
|
+
"Rakefile",
|
21
|
+
"VERSION",
|
22
|
+
"demos/simple/public/temps/portland/2007.csv",
|
23
|
+
"demos/simple/sparkplug_demo.rb",
|
24
|
+
"demos/simple/views/readme.erb",
|
25
|
+
"lib/spark_pr.rb",
|
26
|
+
"lib/sparkplug.rb",
|
27
|
+
"lib/sparkplug/cachers/abstract.rb",
|
28
|
+
"lib/sparkplug/cachers/filesystem.rb",
|
29
|
+
"lib/sparkplug/cachers/memory.rb",
|
30
|
+
"lib/sparkplug/handlers/abstract_data.rb",
|
31
|
+
"lib/sparkplug/handlers/csv_data.rb",
|
32
|
+
"lib/sparkplug/handlers/stubbed_data.rb",
|
33
|
+
"sparkplug.gemspec",
|
34
|
+
"test/sparkplug_test.rb"
|
35
|
+
]
|
36
|
+
s.homepage = %q{http://github.com/technoweenie/sparkplug}
|
37
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
38
|
+
s.require_paths = ["lib"]
|
39
|
+
s.rubygems_version = %q{1.3.4}
|
40
|
+
s.summary = %q{Rack module that dynamically generates sparkline graphs from a set of numbers.}
|
41
|
+
s.test_files = [
|
42
|
+
"test/sparkplug_test.rb"
|
43
|
+
]
|
44
|
+
|
45
|
+
if s.respond_to? :specification_version then
|
46
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
47
|
+
s.specification_version = 3
|
48
|
+
|
49
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
50
|
+
else
|
51
|
+
end
|
52
|
+
else
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
|
4
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
5
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
6
|
+
require 'rack'
|
7
|
+
require 'rack/test'
|
8
|
+
require 'sparkplug'
|
9
|
+
require 'sparkplug/handlers/stubbed_data'
|
10
|
+
require 'sparkplug/handlers/csv_data'
|
11
|
+
require 'sparkplug/cachers/filesystem'
|
12
|
+
require 'sparkplug/cachers/memory'
|
13
|
+
|
14
|
+
class SparkplugTest < Test::Unit::TestCase
|
15
|
+
include Rack::Test::Methods
|
16
|
+
|
17
|
+
$data_dir = File.join(File.dirname(__FILE__), 'data')
|
18
|
+
$stubbed_data = [47, 43, 24, 47, 16, 28, 38, 57, 50, 76, 42, 20, 98, 34, 53, 1, 55, 74, 63, 38, 31, 98, 89]
|
19
|
+
FileUtils.rm_rf $data_dir
|
20
|
+
FileUtils.mkdir_p $data_dir
|
21
|
+
File.open File.join($data_dir, 'stats.csv'), 'wb' do |csv|
|
22
|
+
csv << $stubbed_data.join(",")
|
23
|
+
end
|
24
|
+
sleep 1 # so that the timestamps don't match in the cache check test below
|
25
|
+
|
26
|
+
def app
|
27
|
+
Sparkplug.new \
|
28
|
+
Proc.new {|env| [200, {"Content-Type" => "text/html"}, "booya"] },
|
29
|
+
:handler => Sparkplug::Handlers::StubbedData.new('stats.csv' => {:updated => Time.utc(2009, 1, 1), :contents => $stubbed_data.dup}),
|
30
|
+
:cacher => Sparkplug::Cachers::Filesystem.new($data_dir),
|
31
|
+
:prefix => '/sparks'
|
32
|
+
end
|
33
|
+
|
34
|
+
def setup
|
35
|
+
@stats_png = File.join($data_dir, 'stats.csv.png')
|
36
|
+
FileUtils.rm_rf @stats_png
|
37
|
+
end
|
38
|
+
|
39
|
+
def test_creates_png_from_csv_request
|
40
|
+
assert !File.exist?(@stats_png)
|
41
|
+
get "/sparks/stats.csv.png"
|
42
|
+
assert File.exist?(@stats_png)
|
43
|
+
assert File.size(@stats_png) > 0
|
44
|
+
assert_equal IO.read(@stats_png), last_response.body
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_leaves_recent_cached_png
|
48
|
+
FileUtils.touch(@stats_png)
|
49
|
+
get "/sparks/stats.csv.png"
|
50
|
+
assert_equal '', last_response.body
|
51
|
+
assert_equal 0, File.size(@stats_png)
|
52
|
+
end
|
53
|
+
|
54
|
+
def test_lets_other_requests_fallthrough
|
55
|
+
assert !File.exist?(@stats_png)
|
56
|
+
get "/spark/stats.csv.png"
|
57
|
+
assert_equal 'booya', last_response.body
|
58
|
+
assert !File.exist?(@stats_png)
|
59
|
+
end
|
60
|
+
|
61
|
+
def test_passes_missing_data_requests_through
|
62
|
+
get "/sparks/404.csv.png"
|
63
|
+
assert_equal 'booya', last_response.body
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class SparkplugCSVTest < SparkplugTest
|
68
|
+
def app
|
69
|
+
Sparkplug.new \
|
70
|
+
Proc.new {|env| [200, {"Content-Type" => "text/html"}, "booya"] },
|
71
|
+
:handler => Sparkplug::Handlers::CsvData.new($data_dir),
|
72
|
+
:cacher => Sparkplug::Cachers::Filesystem.new($data_dir),
|
73
|
+
:prefix => '/sparks'
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
class SparkplugMemoryTest < SparkplugTest
|
78
|
+
def app
|
79
|
+
Sparkplug.new \
|
80
|
+
Proc.new {|env| [200, {"Content-Type" => "text/html"}, "booya"] },
|
81
|
+
:handler => Sparkplug::Handlers::StubbedData.new('stats.csv' => {:updated => Time.utc(2009, 1, 1), :contents => $stubbed_data.dup}),
|
82
|
+
:cacher => Sparkplug::Cachers::Memory.new,
|
83
|
+
:prefix => '/sparks'
|
84
|
+
end
|
85
|
+
|
86
|
+
def test_creates_png_from_csv_request
|
87
|
+
get "/sparks/stats.csv.png"
|
88
|
+
assert_equal 1503, last_response.body.size
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_leaves_recent_cached_png
|
92
|
+
# useless test for memory cacher
|
93
|
+
end
|
94
|
+
end
|
metadata
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sparkplug
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 2.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- rick
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-11-01 00:00:00 -07:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description:
|
17
|
+
email: technoweenie@gmail.com
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- LICENSE
|
24
|
+
- README.rdoc
|
25
|
+
files:
|
26
|
+
- .document
|
27
|
+
- .gitignore
|
28
|
+
- LICENSE
|
29
|
+
- README.rdoc
|
30
|
+
- Rakefile
|
31
|
+
- VERSION
|
32
|
+
- demos/simple/public/temps/portland/2007.csv
|
33
|
+
- demos/simple/sparkplug_demo.rb
|
34
|
+
- demos/simple/views/readme.erb
|
35
|
+
- lib/spark_pr.rb
|
36
|
+
- lib/sparkplug.rb
|
37
|
+
- lib/sparkplug/cachers/abstract.rb
|
38
|
+
- lib/sparkplug/cachers/filesystem.rb
|
39
|
+
- lib/sparkplug/cachers/memory.rb
|
40
|
+
- lib/sparkplug/handlers/abstract_data.rb
|
41
|
+
- lib/sparkplug/handlers/csv_data.rb
|
42
|
+
- lib/sparkplug/handlers/stubbed_data.rb
|
43
|
+
- sparkplug.gemspec
|
44
|
+
- test/sparkplug_test.rb
|
45
|
+
has_rdoc: true
|
46
|
+
homepage: http://github.com/technoweenie/sparkplug
|
47
|
+
licenses: []
|
48
|
+
|
49
|
+
post_install_message:
|
50
|
+
rdoc_options:
|
51
|
+
- --charset=UTF-8
|
52
|
+
require_paths:
|
53
|
+
- lib
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: "0"
|
59
|
+
version:
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - ">="
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: "0"
|
65
|
+
version:
|
66
|
+
requirements: []
|
67
|
+
|
68
|
+
rubyforge_project:
|
69
|
+
rubygems_version: 1.3.4
|
70
|
+
signing_key:
|
71
|
+
specification_version: 3
|
72
|
+
summary: Rack module that dynamically generates sparkline graphs from a set of numbers.
|
73
|
+
test_files:
|
74
|
+
- test/sparkplug_test.rb
|