birdwatcher 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/Rakefile +10 -0
- data/bin/console +42 -0
- data/birdwatcher.gemspec +40 -0
- data/data/english_stopwords.txt +319 -0
- data/data/top100Kenglishwords.txt +100000 -0
- data/db/migrations/001_create_workspaces.rb +11 -0
- data/db/migrations/002_create_users.rb +29 -0
- data/db/migrations/003_create_statuses.rb +28 -0
- data/db/migrations/004_create_mentions.rb +13 -0
- data/db/migrations/005_create_mentions_statuses.rb +8 -0
- data/db/migrations/006_create_hashtags.rb +11 -0
- data/db/migrations/007_create_hashtags_statuses.rb +8 -0
- data/db/migrations/008_create_urls.rb +16 -0
- data/db/migrations/009_create_statuses_urls.rb +8 -0
- data/db/migrations/010_create_klout_topics.rb +10 -0
- data/db/migrations/011_create_klout_topics_users.rb +8 -0
- data/db/migrations/012_create_influencers.rb +10 -0
- data/db/migrations/013_create_influencers_users.rb +8 -0
- data/db/migrations/014_create_influencees.rb +10 -0
- data/db/migrations/015_create_influencees_users.rb +8 -0
- data/exe/birdwatcher +12 -0
- data/lib/birdwatcher/command.rb +78 -0
- data/lib/birdwatcher/commands/back.rb +15 -0
- data/lib/birdwatcher/commands/exit.rb +16 -0
- data/lib/birdwatcher/commands/help.rb +60 -0
- data/lib/birdwatcher/commands/irb.rb +34 -0
- data/lib/birdwatcher/commands/module.rb +106 -0
- data/lib/birdwatcher/commands/query.rb +58 -0
- data/lib/birdwatcher/commands/query_csv.rb +56 -0
- data/lib/birdwatcher/commands/resource.rb +45 -0
- data/lib/birdwatcher/commands/run.rb +19 -0
- data/lib/birdwatcher/commands/schema.rb +116 -0
- data/lib/birdwatcher/commands/set.rb +56 -0
- data/lib/birdwatcher/commands/shell.rb +21 -0
- data/lib/birdwatcher/commands/show.rb +86 -0
- data/lib/birdwatcher/commands/status.rb +114 -0
- data/lib/birdwatcher/commands/unset.rb +37 -0
- data/lib/birdwatcher/commands/use.rb +25 -0
- data/lib/birdwatcher/commands/user.rb +155 -0
- data/lib/birdwatcher/commands/workspace.rb +176 -0
- data/lib/birdwatcher/concerns/concurrency.rb +25 -0
- data/lib/birdwatcher/concerns/core.rb +105 -0
- data/lib/birdwatcher/concerns/outputting.rb +114 -0
- data/lib/birdwatcher/concerns/persistence.rb +101 -0
- data/lib/birdwatcher/concerns/presentation.rb +122 -0
- data/lib/birdwatcher/concerns/util.rb +138 -0
- data/lib/birdwatcher/configuration.rb +63 -0
- data/lib/birdwatcher/configuration_wizard.rb +65 -0
- data/lib/birdwatcher/console.rb +201 -0
- data/lib/birdwatcher/http_client.rb +164 -0
- data/lib/birdwatcher/klout_client.rb +83 -0
- data/lib/birdwatcher/kml.rb +125 -0
- data/lib/birdwatcher/module.rb +253 -0
- data/lib/birdwatcher/modules/statuses/kml.rb +106 -0
- data/lib/birdwatcher/modules/statuses/sentiment.rb +77 -0
- data/lib/birdwatcher/modules/statuses/word_cloud.rb +205 -0
- data/lib/birdwatcher/modules/urls/crawl.rb +138 -0
- data/lib/birdwatcher/modules/urls/most_shared.rb +98 -0
- data/lib/birdwatcher/modules/users/activity_plot.rb +62 -0
- data/lib/birdwatcher/modules/users/import.rb +61 -0
- data/lib/birdwatcher/modules/users/influence_graph.rb +93 -0
- data/lib/birdwatcher/modules/users/klout_id.rb +62 -0
- data/lib/birdwatcher/modules/users/klout_influence.rb +83 -0
- data/lib/birdwatcher/modules/users/klout_score.rb +64 -0
- data/lib/birdwatcher/modules/users/klout_topics.rb +72 -0
- data/lib/birdwatcher/modules/users/social_graph.rb +110 -0
- data/lib/birdwatcher/punchcard.rb +183 -0
- data/lib/birdwatcher/util.rb +83 -0
- data/lib/birdwatcher/version.rb +3 -0
- data/lib/birdwatcher.rb +43 -0
- data/models/hashtag.rb +8 -0
- data/models/influencee.rb +8 -0
- data/models/influencer.rb +8 -0
- data/models/klout_topic.rb +8 -0
- data/models/mention.rb +8 -0
- data/models/status.rb +11 -0
- data/models/url.rb +8 -0
- data/models/user.rb +11 -0
- data/models/workspace.rb +26 -0
- metadata +405 -0
@@ -0,0 +1,62 @@
|
|
1
|
+
module Birdwatcher
|
2
|
+
module Modules
|
3
|
+
module Users
|
4
|
+
class KloutId < Birdwatcher::Module
|
5
|
+
self.meta = {
|
6
|
+
:name => "User Klout IDs",
|
7
|
+
:description => "Enrich users with their Klout ID",
|
8
|
+
:author => "Michael Henriksen <michenriksen@neomailbox.ch>",
|
9
|
+
:options => {
|
10
|
+
"THREADS" => {
|
11
|
+
:value => 5,
|
12
|
+
:description => "Number of concurrent threads",
|
13
|
+
:required => false
|
14
|
+
}
|
15
|
+
}
|
16
|
+
}
|
17
|
+
|
18
|
+
def self.info
|
19
|
+
<<-INFO
|
20
|
+
The User Klout IDs module can populate the current workspace's users with their
|
21
|
+
Klout ID. Having a Klout ID on users makes it possible to gather information
|
22
|
+
with other modules such as #{'users/klout_influence'.bold}, #{'users/klout_topics'.bold} and
|
23
|
+
#{'users/klout_score'.bold}.
|
24
|
+
|
25
|
+
#{'Note:'.bold} Birdwatcher must be configured with one or more Klout API keys
|
26
|
+
in order to work.
|
27
|
+
INFO
|
28
|
+
end
|
29
|
+
|
30
|
+
def run
|
31
|
+
if !klout_client
|
32
|
+
error("Birdwatcher has not been configured with any Klout API keys")
|
33
|
+
return false
|
34
|
+
end
|
35
|
+
users = current_workspace.users_dataset.order(:screen_name)
|
36
|
+
if users.empty?
|
37
|
+
error("There are no users to process")
|
38
|
+
return false
|
39
|
+
end
|
40
|
+
threads = thread_pool(option_setting("THREADS").to_i)
|
41
|
+
users.each do |user|
|
42
|
+
threads.process do
|
43
|
+
begin
|
44
|
+
klout_id = klout_client.get_id(user.screen_name)
|
45
|
+
if klout_id.nil?
|
46
|
+
warn("User #{user.screen_name.bold} doesn't have a Klout ID; skipping")
|
47
|
+
next
|
48
|
+
end
|
49
|
+
user.klout_id = klout_id
|
50
|
+
user.save
|
51
|
+
info("User #{user.screen_name.bold} has a Klout ID: #{klout_id.to_s.bold}")
|
52
|
+
rescue => e
|
53
|
+
error("Processing of #{user.screen_name.bold} failed (#{e.class})")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
threads.shutdown
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Birdwatcher
|
2
|
+
module Modules
|
3
|
+
module Users
|
4
|
+
class KloutInfluence < Birdwatcher::Module
|
5
|
+
self.meta = {
|
6
|
+
:name => "User Klout Influence",
|
7
|
+
:description => "Enrich users with their Klout influence",
|
8
|
+
:author => "Michael Henriksen <michenriksen@neomailbox.ch>",
|
9
|
+
:options => {
|
10
|
+
"THREADS" => {
|
11
|
+
:value => 5,
|
12
|
+
:description => "Number of concurrent threads",
|
13
|
+
:required => false
|
14
|
+
}
|
15
|
+
}
|
16
|
+
}
|
17
|
+
|
18
|
+
def self.info
|
19
|
+
<<-INFO
|
20
|
+
The User Klout Influence module can be used to gather an influence graph of all
|
21
|
+
users in the currently active workspace. The Klout Influence API can tell who
|
22
|
+
users are being influenced by as well as who they are influencing.
|
23
|
+
|
24
|
+
The influence graph can be generated with the #{'users/influence_graph'.bold} module
|
25
|
+
when the raw data has been gathered with this module.
|
26
|
+
|
27
|
+
#{'Note:'.bold} This module requires that users have been enriched with their
|
28
|
+
Klout ID from the #{'users/klout_id'.bold} module. It also requires that Birdwatcher has
|
29
|
+
been configured with one or more Klout API keys in order to work.
|
30
|
+
INFO
|
31
|
+
end
|
32
|
+
|
33
|
+
def run
|
34
|
+
if !klout_client
|
35
|
+
error("Birdwatcher has not been configured with any Klout API keys")
|
36
|
+
return false
|
37
|
+
end
|
38
|
+
users = current_workspace.users_dataset.where("klout_id IS NOT NULL").order(:screen_name)
|
39
|
+
if users.empty?
|
40
|
+
error("There are no users with Klout IDs")
|
41
|
+
return false
|
42
|
+
end
|
43
|
+
threads = thread_pool(option_setting("THREADS").to_i)
|
44
|
+
influencer_mutex = Mutex.new
|
45
|
+
influencee_mutex = Mutex.new
|
46
|
+
users.each do |user|
|
47
|
+
threads.process do
|
48
|
+
begin
|
49
|
+
if influence = klout_client.get_influence(user.klout_id)
|
50
|
+
influence[:influencers].each do |screen_name|
|
51
|
+
db_influencer = influencer_mutex.synchronize do
|
52
|
+
Birdwatcher::Models::Influencer.find_or_create(:workspace_id => current_workspace.id, :screen_name => screen_name)
|
53
|
+
end
|
54
|
+
if !user.influencers.include?(db_influencer)
|
55
|
+
user.add_influencer(db_influencer)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
influence[:influencees].each do |screen_name|
|
59
|
+
db_influencee = influencee_mutex.synchronize do
|
60
|
+
Birdwatcher::Models::Influencee.find_or_create(:workspace_id => current_workspace.id, :screen_name => screen_name)
|
61
|
+
end
|
62
|
+
if !user.influencees.include?(db_influencee)
|
63
|
+
user.add_influencee(db_influencee)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
info("User #{user.screen_name.bold} is influenced by: #{influence[:influencers].map(&:bold).join(', ')}")
|
67
|
+
info("User #{user.screen_name.bold} is influencing: #{influence[:influencees].map(&:bold).join(', ')}")
|
68
|
+
else
|
69
|
+
error("Could not get Klout influence for #{user.screen_name.bold}")
|
70
|
+
end
|
71
|
+
user.save
|
72
|
+
rescue => e
|
73
|
+
error("Processing of #{user.screen_name.bold} failed (#{e.class})")
|
74
|
+
info(e.backtrace.join("\n"))
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
threads.shutdown
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Birdwatcher
|
2
|
+
module Modules
|
3
|
+
module Users
|
4
|
+
class KloutScore < Birdwatcher::Module
|
5
|
+
self.meta = {
|
6
|
+
:name => "User Klout Score",
|
7
|
+
:description => "Enrich users with their Klout score",
|
8
|
+
:author => "Michael Henriksen <michenriksen@neomailbox.ch>",
|
9
|
+
:options => {
|
10
|
+
"THREADS" => {
|
11
|
+
:value => 5,
|
12
|
+
:description => "Number of concurrent threads",
|
13
|
+
:required => false
|
14
|
+
}
|
15
|
+
}
|
16
|
+
}
|
17
|
+
|
18
|
+
def self.info
|
19
|
+
<<-INFO
|
20
|
+
The User Klout Score module can be used to retrieve the Klout Score of all users
|
21
|
+
in the currently active workspace.
|
22
|
+
|
23
|
+
The Klout score is a score between 1-100 and represents a user's influence. The
|
24
|
+
more influential a user is, the higher their Klout score. Read more about how it
|
25
|
+
works here: https://klout.com/corp/score
|
26
|
+
|
27
|
+
#{'Note:'.bold} This module requires that users have been enriched with their
|
28
|
+
Klout ID from the #{'users/klout_id'.bold} module. It also requires that Birdwatcher has
|
29
|
+
been configured with one or more Klout API keys in order to work.
|
30
|
+
INFO
|
31
|
+
end
|
32
|
+
|
33
|
+
def run
|
34
|
+
if !klout_client
|
35
|
+
error("Birdwatcher has not been configured with any Klout API keys")
|
36
|
+
return false
|
37
|
+
end
|
38
|
+
users = current_workspace.users_dataset.where("klout_id IS NOT NULL").order(:screen_name)
|
39
|
+
if users.empty?
|
40
|
+
error("There are no users with Klout IDs")
|
41
|
+
return false
|
42
|
+
end
|
43
|
+
threads = thread_pool(option_setting("THREADS").to_i)
|
44
|
+
users.each do |user|
|
45
|
+
threads.process do
|
46
|
+
begin
|
47
|
+
if klout_score = klout_client.get_score(user.klout_id)
|
48
|
+
user.klout_score = klout_score
|
49
|
+
info("User #{user.screen_name.bold} has a Klout score of #{klout_score.to_s.bold}")
|
50
|
+
else
|
51
|
+
error("Could not get Klout score for #{user.screen_name.bold}")
|
52
|
+
end
|
53
|
+
user.save
|
54
|
+
rescue => e
|
55
|
+
error("Processing of #{user.screen_name.bold} failed (#{e.class})")
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
threads.shutdown
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module Birdwatcher
|
2
|
+
module Modules
|
3
|
+
module Users
|
4
|
+
class KloutTopics < Birdwatcher::Module
|
5
|
+
self.meta = {
|
6
|
+
:name => "User Klout Topics",
|
7
|
+
:description => "Enrich users with their Klout topics",
|
8
|
+
:author => "Michael Henriksen <michenriksen@neomailbox.ch>",
|
9
|
+
:options => {
|
10
|
+
"THREADS" => {
|
11
|
+
:value => 5,
|
12
|
+
:description => "Number of concurrent threads",
|
13
|
+
:required => false
|
14
|
+
}
|
15
|
+
}
|
16
|
+
}
|
17
|
+
|
18
|
+
def self.info
|
19
|
+
<<-INFO
|
20
|
+
The User Klout Topics module can be used to retrieve the general topics that
|
21
|
+
users in the currently active workspace are tweeting about.
|
22
|
+
|
23
|
+
#{'Note:'.bold} This module requires that users have been enriched with their
|
24
|
+
Klout ID from the #{'users/klout_id'.bold} module. It also requires that Birdwatcher has
|
25
|
+
been configured with one or more Klout API keys in order to work.
|
26
|
+
INFO
|
27
|
+
end
|
28
|
+
|
29
|
+
def run
|
30
|
+
if !klout_client
|
31
|
+
error("Birdwatcher has not been configured with any Klout API keys")
|
32
|
+
return false
|
33
|
+
end
|
34
|
+
users = current_workspace.users_dataset.where("klout_id IS NOT NULL").order(:screen_name)
|
35
|
+
if users.empty?
|
36
|
+
error("There are no users with Klout IDs")
|
37
|
+
return false
|
38
|
+
end
|
39
|
+
threads = thread_pool(option_setting("THREADS").to_i)
|
40
|
+
mutex = Mutex.new
|
41
|
+
users.each do |user|
|
42
|
+
threads.process do
|
43
|
+
begin
|
44
|
+
if klout_topics = klout_client.get_topics(user.klout_id)
|
45
|
+
if klout_topics.empty?
|
46
|
+
warn("User #{user.screen_name.bold} has no topics; skipping")
|
47
|
+
next
|
48
|
+
end
|
49
|
+
klout_topics.each do |topic|
|
50
|
+
db_topic = mutex.synchronize do
|
51
|
+
Birdwatcher::Models::KloutTopic.find_or_create(:workspace_id => current_workspace.id, :topic => topic)
|
52
|
+
end
|
53
|
+
if !user.klout_topics.include?(db_topic)
|
54
|
+
user.add_klout_topic(db_topic)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
info("User #{user.screen_name.bold} has topics: #{klout_topics.map(&:bold).join(', ')}")
|
58
|
+
else
|
59
|
+
error("Could not get Klout topics for #{user.screen_name.bold}")
|
60
|
+
end
|
61
|
+
user.save
|
62
|
+
rescue => e
|
63
|
+
error("Processing of #{user.screen_name.bold} failed (#{e.class})")
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
threads.shutdown
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module Birdwatcher
|
2
|
+
module Modules
|
3
|
+
module Users
|
4
|
+
class SocialGraph < Birdwatcher::Module
|
5
|
+
self.meta = {
|
6
|
+
:name => "Social Graph",
|
7
|
+
:description => "Graphs the social relations between users",
|
8
|
+
:author => "Michael Henriksen <michenriksen@neomailbox.ch>",
|
9
|
+
:options => {
|
10
|
+
"DEST" => {
|
11
|
+
:value => nil,
|
12
|
+
:description => "Destination file",
|
13
|
+
:required => true
|
14
|
+
},
|
15
|
+
"USERS" => {
|
16
|
+
:value => nil,
|
17
|
+
:description => "Space-separated list of screen names (all users if empty)",
|
18
|
+
:required => false
|
19
|
+
},
|
20
|
+
"MIN_WEIGHT" => {
|
21
|
+
:value => 10,
|
22
|
+
:description => "Percentage of the highest edge weight to be considered minimum edge weight",
|
23
|
+
:required => true
|
24
|
+
},
|
25
|
+
"FORMAT" => {
|
26
|
+
:value => "png",
|
27
|
+
:description => "Destination file format (any format supported by Graphviz)",
|
28
|
+
:required => true
|
29
|
+
}
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
def self.info
|
34
|
+
<<-INFO
|
35
|
+
The Social Graph module generates an undirected graph between users in the
|
36
|
+
currently active workspace. The edges between users will be weighted by simply
|
37
|
+
counting the amount of times they mutually mention each other in statuses. The
|
38
|
+
module can identify social clusters within the collection of users.
|
39
|
+
|
40
|
+
The generated graph is in PNG format.
|
41
|
+
INFO
|
42
|
+
end
|
43
|
+
|
44
|
+
def run
|
45
|
+
if !GraphViz::Constants::FORMATS.include?(option_setting("FORMAT"))
|
46
|
+
error("Unsupported format: #{option_setting('FORMAT').bold}")
|
47
|
+
return false
|
48
|
+
end
|
49
|
+
if screen_names = option_setting("USERS")
|
50
|
+
users = current_workspace.users_dataset
|
51
|
+
.where("screen_name IN ?", screen_names.split(" ").map(&:strip))
|
52
|
+
.order(:screen_name)
|
53
|
+
else
|
54
|
+
users = current_workspace.users_dataset.order(:screen_name)
|
55
|
+
end
|
56
|
+
if users.empty?
|
57
|
+
error("There are no users to process")
|
58
|
+
return false
|
59
|
+
end
|
60
|
+
graph = GraphViz.new(:G, :type => :graph, :use => "sfdp", :overlap => "prism", :splines => "curved")
|
61
|
+
edge_weights = {}
|
62
|
+
highest_edge_weight = 0
|
63
|
+
nodes = {}
|
64
|
+
users.each do |user|
|
65
|
+
task("Calculating social graph for #{user.screen_name.bold}...") do
|
66
|
+
users.each do |other_user|
|
67
|
+
next if user.id == other_user.id
|
68
|
+
edge_weights[user.screen_name] ||= {}
|
69
|
+
edge_weights[other_user.screen_name] ||= {}
|
70
|
+
next if (edge_weights[user.screen_name].key?(other_user.screen_name) && edge_weights[other_user.screen_name].key?(user.screen_name))
|
71
|
+
edge_weights[user.screen_name][other_user.screen_name] = user.statuses_dataset.where("text LIKE ?", "%#{other_user.screen_name}%").count
|
72
|
+
edge_weights[other_user.screen_name][user.screen_name] = other_user.statuses_dataset.where("text LIKE ?", "%#{user.screen_name}%").count
|
73
|
+
total_edge_weight = edge_weights[user.screen_name][other_user.screen_name] + edge_weights[other_user.screen_name][user.screen_name]
|
74
|
+
highest_edge_weight = total_edge_weight if (total_edge_weight > highest_edge_weight)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
task("Generating social graph...") do
|
79
|
+
edge_weights.each_pair do |user, user_graph|
|
80
|
+
user_graph.each_pair do |other_user, edge_weight|
|
81
|
+
total_edge_weight = edge_weight + edge_weights[other_user][user]
|
82
|
+
percentage = (total_edge_weight.to_f / highest_edge_weight.to_f * 100).to_i
|
83
|
+
if percentage >= option_setting("MIN_WEIGHT").to_i
|
84
|
+
nodes[user] ||= graph.add_nodes(user)
|
85
|
+
nodes[other_user] ||= graph.add_nodes(other_user)
|
86
|
+
case percentage
|
87
|
+
when 0..25
|
88
|
+
pen_width = 1
|
89
|
+
when 26..50
|
90
|
+
pen_width = 2
|
91
|
+
when 51..75
|
92
|
+
pen_width = 4
|
93
|
+
when 76..100
|
94
|
+
pen_width = 5
|
95
|
+
end
|
96
|
+
graph.add_edges(nodes[user], nodes[other_user], :weight => total_edge_weight, :penwidth => pen_width, :color => "lightblue", :fontcolor => "cornflowerblue", :label => total_edge_weight.to_s)
|
97
|
+
edge_weights[other_user].delete(user)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
task("Outputting graph...") do
|
103
|
+
graph.output(option_setting("FORMAT") => option_setting("DEST"))
|
104
|
+
end
|
105
|
+
info("Graph written to #{option_setting('DEST').bold}")
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,183 @@
|
|
1
|
+
# Code adapted from https://github.com/jashank/punchcard-plot
|
2
|
+
|
3
|
+
module Birdwatcher
|
4
|
+
class Punchcard
|
5
|
+
LEFT_PADDING = 10
|
6
|
+
TOP_PADDING = 10
|
7
|
+
|
8
|
+
WIDTH = 1100
|
9
|
+
|
10
|
+
DAYS = %w(Sat Fri Thu Wed Tue Mon Sun)
|
11
|
+
HOURS = %w(12am 1 2 3 4 5 6 7 8 9 10 11 12pm 1 2 3 4 5 6 7 8 9 10 11)
|
12
|
+
|
13
|
+
FONT_FACE = "sans-serif"
|
14
|
+
|
15
|
+
def initialize(timestamps)
|
16
|
+
@timestamps = timestamps
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate(destination)
|
20
|
+
@data_log = Hash.new { |h, k| h[k] = Hash.new }
|
21
|
+
final_data = []
|
22
|
+
@timestamps.each do |timestamp|
|
23
|
+
day = timestamp.strftime("%a")
|
24
|
+
hour = timestamp.strftime("%H").to_i
|
25
|
+
@data_log[day][hour] = (@data_log[day][hour] || 0) + 1
|
26
|
+
end
|
27
|
+
@data_log.each do |d, hour_pair|
|
28
|
+
hour_pair.each do |h, value|
|
29
|
+
glr = @data_log[d][h] * 1.0
|
30
|
+
glr /= max_value
|
31
|
+
glr *= max_range
|
32
|
+
glrb = get_weight(glr)
|
33
|
+
final_data.push([glrb, get_x_y_from_day_and_hour(d, h)])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
final_data.each do |x|
|
37
|
+
draw_circle(x[1], x[0])
|
38
|
+
end
|
39
|
+
surface.write_to_png(destination)
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def width
|
45
|
+
WIDTH
|
46
|
+
end
|
47
|
+
|
48
|
+
def height
|
49
|
+
(width / 2.75).round(0)
|
50
|
+
end
|
51
|
+
|
52
|
+
def distance
|
53
|
+
distance = Math.sqrt((width * height) / 270.5).round
|
54
|
+
if distance % 2 == 1
|
55
|
+
distance -= 1
|
56
|
+
end
|
57
|
+
distance
|
58
|
+
end
|
59
|
+
|
60
|
+
def max_range
|
61
|
+
(distance / 2)**2
|
62
|
+
end
|
63
|
+
|
64
|
+
def left
|
65
|
+
(width / 18) + LEFT_PADDING
|
66
|
+
end
|
67
|
+
|
68
|
+
def indicator_length
|
69
|
+
height / 20
|
70
|
+
end
|
71
|
+
|
72
|
+
def top
|
73
|
+
indicator_length + TOP_PADDING
|
74
|
+
end
|
75
|
+
|
76
|
+
def get_x_y_from_day_and_hour(day, hour)
|
77
|
+
y = top + (DAYS.index(day.to_s) + 1) * distance
|
78
|
+
x = left + (hour.to_i + 1) * distance
|
79
|
+
[x, y]
|
80
|
+
end
|
81
|
+
|
82
|
+
def get_weight(number)
|
83
|
+
return 0 if number.zero?
|
84
|
+
(1..(distance / 2)).to_a.each do |i|
|
85
|
+
if i * i <= number && number < (i + 1) * (i + 1)
|
86
|
+
return i
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
if number == max_range
|
91
|
+
return distance/2-1
|
92
|
+
end
|
93
|
+
|
94
|
+
nil
|
95
|
+
end
|
96
|
+
|
97
|
+
def all_values
|
98
|
+
@all_values = []
|
99
|
+
@data_log.each do |d, e|
|
100
|
+
e.each do |h, i|
|
101
|
+
@all_values << @data_log[d][h]
|
102
|
+
end
|
103
|
+
end
|
104
|
+
@all_values
|
105
|
+
end
|
106
|
+
|
107
|
+
def max_value
|
108
|
+
all_values.sort.last
|
109
|
+
end
|
110
|
+
|
111
|
+
def surface
|
112
|
+
@surface ||= Cairo::ImageSurface.new(Cairo::FORMAT_ARGB32, width, height)
|
113
|
+
end
|
114
|
+
|
115
|
+
def context
|
116
|
+
@context ||= Cairo::Context.new(surface).tap do |c|
|
117
|
+
c.line_width = 1
|
118
|
+
c.set_source_rgb(1, 1, 1)
|
119
|
+
c.rectangle(0, 0, width, height)
|
120
|
+
c.fill
|
121
|
+
|
122
|
+
# Set black
|
123
|
+
c.set_source_rgb(0, 0, 0)
|
124
|
+
|
125
|
+
# Draw X and Y axis
|
126
|
+
c.move_to(left, top)
|
127
|
+
c.rel_line_to(0, 8 * distance)
|
128
|
+
c.rel_line_to(25 * distance, 0)
|
129
|
+
c.stroke
|
130
|
+
|
131
|
+
# Draw indicators on X and Y axis
|
132
|
+
x, y = left, top
|
133
|
+
8.times do
|
134
|
+
c.move_to(x, y)
|
135
|
+
c.rel_line_to(-indicator_length, 0)
|
136
|
+
c.stroke
|
137
|
+
y += distance
|
138
|
+
end
|
139
|
+
|
140
|
+
x += distance
|
141
|
+
26.times do
|
142
|
+
c.move_to(x, y)
|
143
|
+
c.rel_line_to(0, indicator_length)
|
144
|
+
c.stroke
|
145
|
+
x += distance
|
146
|
+
end
|
147
|
+
|
148
|
+
# Select font
|
149
|
+
c.select_font_face(FONT_FACE, Cairo::FONT_SLANT_NORMAL, Cairo::FONT_WEIGHT_NORMAL)
|
150
|
+
|
151
|
+
# Set and appropiate font size
|
152
|
+
c.set_font_size(Math.sqrt( (width * height) / 3055.6))
|
153
|
+
|
154
|
+
# Draw days on Y axis
|
155
|
+
x, y = (left - 5), (top + distance)
|
156
|
+
DAYS.each do |day|
|
157
|
+
t_ext = c.text_extents(day.to_s)
|
158
|
+
c.move_to(x - indicator_length - t_ext.width, y + t_ext.height / 2)
|
159
|
+
c.show_text(day.to_s)
|
160
|
+
y += distance
|
161
|
+
end
|
162
|
+
|
163
|
+
# Draw hours on X axis
|
164
|
+
x, y = (left + distance), (top + (7 + 1) * distance + 5)
|
165
|
+
HOURS.each do |hour|
|
166
|
+
t_ext = c.text_extents(hour.to_s)
|
167
|
+
c.move_to(x - t_ext.width / 2 - t_ext.x_bearing, y + indicator_length + t_ext.height / 2)
|
168
|
+
c.show_text(hour.to_s)
|
169
|
+
x += distance
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def draw_circle(position, weight)
|
175
|
+
x, y = position
|
176
|
+
alpha = (weight.to_f / max_value.to_f)
|
177
|
+
context.set_source_rgba(0, 0, 0, alpha)
|
178
|
+
context.move_to(x, y)
|
179
|
+
context.arc(x, y, weight, 0, 2 * Math::PI)
|
180
|
+
context.fill
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Birdwatcher
|
2
|
+
module Util
|
3
|
+
def self.time_ago_in_words(time)
|
4
|
+
return "a very very long time ago" if time.year < 1800
|
5
|
+
secs = Time.now - time
|
6
|
+
return "just now" if secs > -1 && secs < 1
|
7
|
+
return "" if secs <= -1
|
8
|
+
pair = ago_in_words_pair(secs)
|
9
|
+
ary = ago_in_words_singularize(pair)
|
10
|
+
ary.size == 0 ? "" : ary.join(" and ") << " ago"
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.ago_in_words_pair(secs)
|
14
|
+
[[60, :seconds], [60, :minutes], [24, :hours], [100_000, :days]].map{ |count, name|
|
15
|
+
if secs > 0
|
16
|
+
secs, n = secs.divmod(count)
|
17
|
+
"#{n.to_i} #{name}"
|
18
|
+
end
|
19
|
+
}.compact.reverse[0..1]
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.ago_in_words_singularize(pair)
|
23
|
+
if pair.size == 1
|
24
|
+
pair.map! {|part| part[0, 2].to_i == 1 ? part.chomp("s") : part }
|
25
|
+
else
|
26
|
+
pair.map! {|part| part[0, 2].to_i == 1 ? part.chomp("s") : part[0, 2].to_i == 0 ? nil : part }
|
27
|
+
end
|
28
|
+
pair.compact
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.parse_time(time)
|
32
|
+
::Chronic.parse(time)
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.strip_html(string)
|
36
|
+
string.to_s.gsub(/<\/?[^>]*>/, "")
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.strip_control_characters(string)
|
40
|
+
string = string.to_s.uncolorize
|
41
|
+
string.split("").delete_if do |char|
|
42
|
+
char.ascii_only? and (char.ord < 32 or char.ord == 127)
|
43
|
+
end.join("")
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.escape_html(string)
|
47
|
+
CGI.escapeHTML(string.to_s)
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.unescape_html(string)
|
51
|
+
CGI.unescapeHTML(string.to_s)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.pluralize(count, singular, plural)
|
55
|
+
count == 1 ? "1 #{singular}" : "#{count} #{plural}"
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.excerpt(text, max_length, omission = "...")
|
59
|
+
text = text.gsub(/\s/, " ").split(" ").map(&:strip).join(" ")
|
60
|
+
return text if text.length < max_length
|
61
|
+
text[0..max_length] + omission
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.suppress_output(&block)
|
65
|
+
original_stdout = $stdout
|
66
|
+
$stdout = fake = StringIO.new
|
67
|
+
begin
|
68
|
+
yield
|
69
|
+
ensure
|
70
|
+
$stdout = original_stdout
|
71
|
+
end
|
72
|
+
fake.string
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.suppress_warnings(&block)
|
76
|
+
warn_level = $VERBOSE
|
77
|
+
$VERBOSE = nil
|
78
|
+
result = block.call
|
79
|
+
$VERBOSE = warn_level
|
80
|
+
result
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|