birdwatcher 0.1.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.
- 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
|