Changed tweets, added eca
This commit is contained in:
24
README.md
Normal file
24
README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
ECA - Event Condition Action
|
||||
=================================
|
||||
|
||||
ECA was developed as an educational tool aimed at a diverse CS student
|
||||
population with programming experience ranging from "very little" to "very
|
||||
much": it allows students to quickly develop a prototype real-time Twitter
|
||||
Dashboard that really looks cool. And it is open to more advanced programming
|
||||
to challenge students who have more experience.
|
||||
The basis of ECA is a rule system that reacts to externally generated events.
|
||||
|
||||
The architecture favours simplicity over robustness. Multithreading is used in
|
||||
favour of more suitable parallelism options such as greenlets to keep
|
||||
dependencies to a minimum for ease of deployment.
|
||||
|
||||
Documentation can be found in the Wiki of this github site.
|
||||
|
||||
This program is not intended for production use. It may contain security issues
|
||||
not tolerable outside of a controlled environment.
|
||||
|
||||
ECA requires Python 3.2 or higher.
|
||||
|
||||
|
||||
|
||||
|
||||
21
TODO.txt
Normal file
21
TODO.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
[x] Refactor handlers in neca.py to eca/http.py
|
||||
[x] Add session manager to lazy-create new Contexts
|
||||
[x] Context switching through session manager
|
||||
[x] Add SSE handler to allow event-based pushes
|
||||
[x] Add pubsub for SSE handlers to allow emitting from within ECA context
|
||||
[x] Rework static file handling to not be the exception to normal operations
|
||||
[x] Add basic Javascript handling of events
|
||||
[x] Add a way to configure twitter (event generation) input thread
|
||||
[ ] Add control to event generation thread through HTTP interface
|
||||
[x] Document all core modules
|
||||
[ ] Add sample component of a graph/twitter list
|
||||
[ ] Harden system against programmer error (i.e. sanity check all rules-module data flow)
|
||||
[x] Add a delayed event scheduler (for 'fire in 2 seconds' things)
|
||||
[x] locatie tweets http://library.ewi.utwente.nl/ecadata/batatweets.txt
|
||||
[x] Clean up samples and rename leading sample to template.py + template dir
|
||||
[x] Button Block
|
||||
[ ] Session block
|
||||
[x] Full speed tweet time_factor
|
||||
[x] Docs in graph.js
|
||||
[x] emit() in non-server context should doe something useful
|
||||
[x] Rework rules set to non-global object
|
||||
56
demos/advancedcontexts.py
Normal file
56
demos/advancedcontexts.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from eca import *
|
||||
import random
|
||||
|
||||
# declare two separate rule sets
|
||||
|
||||
consumer = Rules()
|
||||
producer = Rules()
|
||||
|
||||
|
||||
# default init
|
||||
|
||||
@event('init')
|
||||
def bootstrap(c, e):
|
||||
# start a few contexts for the generation of stock quotes
|
||||
# (The simple workload here can easily be done in a single context, but we
|
||||
# do this in separate contexts to give an example of the feature.)
|
||||
spawn_context({'symbol':'GOOG', 'start':500.0, 'delay':1.2}, rules=producer, daemon=True)
|
||||
spawn_context({'symbol':'AAPL', 'start':99.0, 'delay':0.9}, rules=producer, daemon=True)
|
||||
spawn_context(rules=consumer, daemon=True)
|
||||
|
||||
@event('end-of-input')
|
||||
def done(c,e):
|
||||
# terminate if the input is closed
|
||||
shutdown()
|
||||
|
||||
|
||||
# producer rules
|
||||
|
||||
@producer.event('init')
|
||||
def start_work(c, e):
|
||||
c.symbol = e.data['symbol']
|
||||
c.delay = e.data['delay']
|
||||
|
||||
fire('sample', {
|
||||
'previous': e.data['start']
|
||||
})
|
||||
|
||||
@producer.event('sample')
|
||||
def work(c, e):
|
||||
current = e.data['previous'] + random.uniform(-0.5, 0.5)
|
||||
|
||||
fire_global('quote', {
|
||||
'symbol': c.symbol,
|
||||
'value': current
|
||||
})
|
||||
|
||||
fire('sample', {
|
||||
'previous': current
|
||||
}, delay=c.delay)
|
||||
|
||||
|
||||
# consumer rules
|
||||
|
||||
@consumer.event('quote')
|
||||
def show_quote(c, e):
|
||||
print("Quote for {symbol}: {value}".format(**e.data))
|
||||
47
demos/average.py
Normal file
47
demos/average.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from eca import *
|
||||
|
||||
|
||||
@event('main')
|
||||
def setup(ctx, e):
|
||||
"""
|
||||
Initialise the context with an accumulator value, and inform
|
||||
the user about the fact that we process input.
|
||||
"""
|
||||
print("Enter a number per line and end with EOF:")
|
||||
print("(EOF is ctrl+d under linux and MacOSes, ctrl+z followed by return under Windows)")
|
||||
ctx.accumulator = 0
|
||||
ctx.count = 0
|
||||
|
||||
|
||||
@event('line')
|
||||
def line(ctx, e):
|
||||
"""
|
||||
Tries to parse the input line as a number and add it to the accumulator.
|
||||
"""
|
||||
try:
|
||||
value = float(e.data) if '.' in e.data else int(e.data)
|
||||
ctx.accumulator += value
|
||||
ctx.count += 1
|
||||
print("sum = " + str(ctx.accumulator))
|
||||
except ValueError:
|
||||
print("'{}' is not a number.".format(e.data))
|
||||
|
||||
|
||||
@event('end-of-input')
|
||||
@condition(lambda c,e: c.count > 0)
|
||||
def done(ctx, e):
|
||||
"""
|
||||
Outputs the final average to the user.
|
||||
"""
|
||||
print("{} samples with average of {}".format(ctx.count, ctx.accumulator / ctx.count))
|
||||
shutdown()
|
||||
|
||||
|
||||
@event('end-of-input')
|
||||
@condition(lambda c,e: c.count == 0)
|
||||
def no_input(ctx, e):
|
||||
"""
|
||||
Invoked of no input is given and input is finished.
|
||||
"""
|
||||
print("0 samples. \"Does not compute!\"")
|
||||
shutdown()
|
||||
40
demos/chat.py
Normal file
40
demos/chat.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from eca import *
|
||||
import datetime
|
||||
import eca.http
|
||||
|
||||
# add message posting handler
|
||||
def add_request_handlers(httpd):
|
||||
httpd.add_route('/api/message', eca.http.GenerateEvent('incoming'), methods=['POST'])
|
||||
|
||||
# use the library content from the template_static dir instead of our own
|
||||
# this is a bit finicky, since execution now depends on a proper working directory.
|
||||
httpd.add_content('/lib/', 'template_static/lib')
|
||||
httpd.add_content('/style/', 'template_static/style')
|
||||
|
||||
|
||||
# store name of context
|
||||
@event('init')
|
||||
def setup(ctx, e):
|
||||
ctx.name = e.data['name']
|
||||
|
||||
|
||||
# emit incoming messages to the client
|
||||
@event('message')
|
||||
def on_message(ctx, e):
|
||||
name = e.data['name']
|
||||
text = e.data['text']
|
||||
time = e.data['time'].strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
emit('message',{
|
||||
'text': "{} @{}: {}".format(name, time, text)
|
||||
})
|
||||
|
||||
|
||||
# do a global fire for each message from the client
|
||||
@event('incoming')
|
||||
def on_incoming(ctx, e):
|
||||
fire_global('message', {
|
||||
'name': ctx.name,
|
||||
'text': e.data['text'],
|
||||
'time': datetime.datetime.now()
|
||||
})
|
||||
44
demos/chat_static/index.html
Normal file
44
demos/chat_static/index.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Neca Test</title>
|
||||
<link rel="stylesheet" href="/style/layout.css"/>
|
||||
<link rel="stylesheet" href="/style/theme.css"/>
|
||||
<script src="/lib/jquery-2.1.1.min.js"></script>
|
||||
<script src="/lib/jquery.flot.min.js"></script>
|
||||
<script src="/lib/core.js"></script>
|
||||
<script src="/lib/form.js"></script>
|
||||
<script src="/lib/log.js"></script>
|
||||
</head>
|
||||
<body class="container_12">
|
||||
<h1>ECA Chat</h1>
|
||||
|
||||
<div id='messages' class='grid_12 vert_4'></div>
|
||||
|
||||
<div id='form' class='grid_8 vert_2'>
|
||||
<form>
|
||||
<textarea name='text' style='width: 100%'></textarea>
|
||||
<input type='submit' value='Send message'/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
block('#messages').log();
|
||||
events.connect('message', '#messages');
|
||||
|
||||
block('#form').form({
|
||||
target: '/api/message',
|
||||
callback: function() {
|
||||
$('textarea').val('');
|
||||
}
|
||||
});
|
||||
|
||||
// small usability tweak
|
||||
$('textarea').keydown(function(e) {
|
||||
if((e.which == 10 || e.which == 13) && e.ctrlKey) {
|
||||
$('#form input[type="submit"]').click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
52
demos/drinks.py
Normal file
52
demos/drinks.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from eca import *
|
||||
import eca.http
|
||||
|
||||
from pprint import pprint
|
||||
|
||||
# This function will be called to set up the HTTP server
|
||||
def add_request_handlers(httpd):
|
||||
# add an event-generating request handler to fire 'order' events
|
||||
# This requires the POST method because the event generation handler only
|
||||
# understands POST requests.
|
||||
httpd.add_route('/api/order', eca.http.GenerateEvent('order'), methods=['POST'])
|
||||
|
||||
# use the library content from the template_static dir instead of our own
|
||||
# this is a bit finicky, since execution now depends on a proper working directory.
|
||||
httpd.add_content('/lib/', 'template_static/lib')
|
||||
httpd.add_content('/style/', 'template_static/style')
|
||||
|
||||
|
||||
@event('order')
|
||||
def order(c, e):
|
||||
# we received an order...
|
||||
|
||||
# ...go an print it
|
||||
print("Received a new order:")
|
||||
pprint(e.data)
|
||||
|
||||
# ...and emit it to all interested browsers
|
||||
# (conceptually, this could also include the Barista's workstation)
|
||||
emit('orders',{
|
||||
'text': str(e.data)
|
||||
});
|
||||
|
||||
|
||||
# Below are some examples of how to handle incoming requests based
|
||||
# on observed qualities.
|
||||
|
||||
# Inform the coffee machine handler that more coffee is required.
|
||||
@event('order')
|
||||
@condition(lambda c,e: e.data['drink'] == 'Coffee')
|
||||
def start_brewing(c,e):
|
||||
print("-> Start the coffee brewer!")
|
||||
|
||||
|
||||
# Check for a very specific order
|
||||
@event('order')
|
||||
@condition(lambda c,e: e.data['drink'] == 'Tea'
|
||||
and not e.data['additives']
|
||||
and e.data['type'].lower() == 'earl grey'
|
||||
and 'hot' in e.data['notes'].lower())
|
||||
def picard_has_arrived(c,e):
|
||||
print("-> Captain Picard has arrived.")
|
||||
|
||||
64
demos/drinks_static/index.html
Normal file
64
demos/drinks_static/index.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Neca Test</title>
|
||||
<link rel="stylesheet" href="/style/layout.css"/>
|
||||
<link rel="stylesheet" href="/style/theme.css"/>
|
||||
<script src="/lib/jquery-2.1.1.min.js"></script>
|
||||
<script src="/lib/jquery.flot.min.js"></script>
|
||||
<script src="/lib/core.js"></script>
|
||||
<script src="/lib/log.js"></script>
|
||||
<script src="/lib/form.js"></script>
|
||||
</head>
|
||||
<body class="container_12">
|
||||
<h1>Hot Drinks Dashboard</h1>
|
||||
|
||||
<!-- Add an order log -->
|
||||
<div id='log' class="grid_6 vert_6"></div>
|
||||
|
||||
<!-- Input form -->
|
||||
<div id='knopjes' class="grid_6 vert_4">
|
||||
<h3>Your Order...</h3>
|
||||
<form>
|
||||
|
||||
<div>
|
||||
<select name='drink'>
|
||||
<option>Coffee</option>
|
||||
<option>Tea</option>
|
||||
</select>
|
||||
|
||||
<label for='type_box'>Type</label> <input type='text' name='type' id='type_box'/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Extras:
|
||||
<input type='checkbox' name='additives' value='sugar' id='sugar_box'/> <label for='sugar_box'>+Sugar</label>
|
||||
<input type='checkbox' name='additives' value='milk' id='milk_box'/> <label for='milk_box'>+Milk</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes_box">Notes:</label>
|
||||
<textarea name='notes' id='notes_box' style="width:100%"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type='radio' name='when' id='when_now' value='now'/><label for='when_now'>Now</label><br>
|
||||
<input type='radio' name='when' id='when_later' value='later'/><label for='when_later'>Later</label>
|
||||
</div>
|
||||
|
||||
<input type='submit' value='Place order'/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// connect order log to 'orders' emit
|
||||
block('#log').log();
|
||||
events.connect('orders', '#log');
|
||||
|
||||
// target the form at the exposed API
|
||||
block('#knopjes').form({
|
||||
target: '/api/order'
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
94
demos/rolling_chart.py
Normal file
94
demos/rolling_chart.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from eca import *
|
||||
|
||||
import random
|
||||
|
||||
# This function will be called to set up the HTTP server
|
||||
def add_request_handlers(httpd):
|
||||
# use the library content from the template_static dir instead of our own
|
||||
# this is a bit finicky, since execution now depends on a proper working directory.
|
||||
httpd.add_content('/lib/', 'template_static/lib')
|
||||
httpd.add_content('/style/', 'template_static/style')
|
||||
|
||||
|
||||
# binds the 'setup' function as the action for the 'init' event
|
||||
# the action will be called with the context and the event
|
||||
@event('init')
|
||||
def setup(ctx, e):
|
||||
ctx.count = 0
|
||||
ctx.samples = {
|
||||
'sensor0': 0.0,
|
||||
'sensor1': 0.0
|
||||
}
|
||||
|
||||
fire('sample', {
|
||||
'previous': 0.0,
|
||||
'name': 'sensor0',
|
||||
'failure-chance': 0.0,
|
||||
'reboot-chance': 1.0,
|
||||
'delay': 0.05
|
||||
})
|
||||
|
||||
fire('sample', {
|
||||
'previous': 0.0,
|
||||
'name': 'sensor1',
|
||||
'failure-chance': 0.05,
|
||||
'reboot-chance': 0.1,
|
||||
'delay': 0.05
|
||||
})
|
||||
|
||||
fire('sample', {
|
||||
'previous': None,
|
||||
'name': 'sensor2',
|
||||
'failure-chance': 0.2,
|
||||
'reboot-chance': 0.8,
|
||||
'delay': 0.1
|
||||
})
|
||||
|
||||
fire('tick')
|
||||
|
||||
|
||||
# define a normal Python function
|
||||
def clip(lower, value, upper):
|
||||
return max(lower, min(value, upper))
|
||||
|
||||
@event('sample')
|
||||
@condition(lambda c,e: e.get('previous') is not None)
|
||||
def generate_sample(ctx, e):
|
||||
sample = e.get('previous')
|
||||
# failure chance off...
|
||||
if e.get('failure-chance') > random.random():
|
||||
sample = None
|
||||
del ctx.samples[e.get('name')]
|
||||
else:
|
||||
# base sample on previous one
|
||||
sample = clip(-100, e.get('previous') + random.uniform(+5.0, -5.0), 100)
|
||||
ctx.samples[e.get('name')] = sample
|
||||
|
||||
# chain event
|
||||
data = dict(e.data)
|
||||
data.update({'previous': sample})
|
||||
fire('sample', data, delay=e.get('delay'))
|
||||
|
||||
@event('sample')
|
||||
@condition(lambda c,e: e.get('previous') is None)
|
||||
def try_reboot(ctx, e):
|
||||
sample = e.get('previous')
|
||||
if e.get('reboot-chance') > random.random():
|
||||
sample = random.uniform(100,-100)
|
||||
ctx.samples[e.get('name')] = sample
|
||||
|
||||
data = dict(e.data)
|
||||
data.update({'previous': sample})
|
||||
fire('sample', data, delay=e.get('delay'))
|
||||
|
||||
|
||||
@event('tick')
|
||||
def tick(ctx, e):
|
||||
# emit to outside world
|
||||
emit('sample',{
|
||||
'action': 'add',
|
||||
'values': ctx.samples
|
||||
})
|
||||
fire('tick', delay=0.05);
|
||||
|
||||
|
||||
65
demos/rolling_chart_static/index.html
Normal file
65
demos/rolling_chart_static/index.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Neca Test</title>
|
||||
<link rel="stylesheet" href="/style/layout.css"/>
|
||||
<link rel="stylesheet" href="/style/theme.css"/>
|
||||
<script src="/lib/jquery-2.1.1.min.js"></script>
|
||||
<script src="/lib/jquery.flot.min.js"></script>
|
||||
<script src="/lib/core.js"></script>
|
||||
<script src="/lib/charts.js"></script>
|
||||
</head>
|
||||
<body class="container_12">
|
||||
<h1>Rolling Chart Dashboard</h1>
|
||||
|
||||
<div id="graph0" class="grid_12 vert_5"></div>
|
||||
<div id="graph1" class="grid_12 vert_5"></div>
|
||||
|
||||
<script>
|
||||
// create a rolling chart block
|
||||
block('#graph0').rolling_chart({
|
||||
memory: 75,
|
||||
series: {
|
||||
'sensor0': {},
|
||||
'sensor1': {}
|
||||
},
|
||||
chart: {
|
||||
yaxis:{
|
||||
min: -100,
|
||||
max: 100
|
||||
},
|
||||
xaxis: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
block('#graph1').rolling_chart({
|
||||
memory: 150,
|
||||
series: {
|
||||
'sensor0': {},
|
||||
'sensor1': {},
|
||||
'sensor2': {
|
||||
lines: {
|
||||
fill: true,
|
||||
steps: true
|
||||
}
|
||||
}
|
||||
},
|
||||
chart: {
|
||||
yaxis:{
|
||||
min: -100,
|
||||
max: 100
|
||||
},
|
||||
xaxis: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// connect sample event to graph
|
||||
events.connect('sample', '#graph0');
|
||||
events.connect('sample', '#graph1');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
52
demos/shout.js
Normal file
52
demos/shout.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* This file is the demo for a block definition. For more information
|
||||
* see:
|
||||
* https://github.com/utwente-db/eca/wiki/Extending:-Creating-Your-Own-Blocks
|
||||
*
|
||||
*/
|
||||
|
||||
(function($, block) {
|
||||
|
||||
block.fn.shout = function(config) {
|
||||
// handle configuration
|
||||
var options = $.extend({
|
||||
size: '64pt',
|
||||
text: 'RED',
|
||||
color: 'red'
|
||||
}, config);
|
||||
|
||||
// create HTML representation
|
||||
var $el = $('<span></span>').appendTo(this.$element);
|
||||
$el.css('font-size', options.size);
|
||||
|
||||
// create HTML element for display
|
||||
var data = {
|
||||
text: options.text,
|
||||
color: options.color
|
||||
}
|
||||
|
||||
// update function to update element
|
||||
var update = function() {
|
||||
$el.text(data.text+'!').css('color', data.color);
|
||||
}
|
||||
|
||||
// invoke update to initialise the display
|
||||
update();
|
||||
|
||||
// register actions
|
||||
this.actions({
|
||||
word: function(e, message) {
|
||||
data.text = message.text;
|
||||
update();
|
||||
},
|
||||
color: function(e, message) {
|
||||
data.color = message.color;
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
// return the element for further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
})(jQuery, block);
|
||||
25
demos/tweet_arff.py
Normal file
25
demos/tweet_arff.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from eca import *
|
||||
from eca.generators import start_offline_tweets
|
||||
|
||||
import datetime
|
||||
import textwrap
|
||||
|
||||
@event('init')
|
||||
def setup(ctx, e):
|
||||
# start the offline tweet stream
|
||||
start_offline_tweets('data/batatweets.txt', 'chirp', time_factor=10000, arff_file='data/batatweets.arff')
|
||||
|
||||
@event('chirp')
|
||||
def tweet(ctx, e):
|
||||
# we receive a tweet
|
||||
tweet = e.data
|
||||
|
||||
# parse date
|
||||
time = datetime.datetime.strptime(tweet['created_at'], '%a %b %d %H:%M:%S %z %Y')
|
||||
|
||||
# nicify text
|
||||
text = textwrap.fill(tweet['text'],initial_indent=' ', subsequent_indent=' ')
|
||||
|
||||
# generate output
|
||||
output = "[{}] {} (@{}) +{}:\n{}".format(time, tweet['user']['name'], tweet['user']['screen_name'], tweet['extra']['@@class@@'], text)
|
||||
emit('tweet', output)
|
||||
25
demos/tweet_rules.py
Normal file
25
demos/tweet_rules.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from eca import *
|
||||
from eca.generators import start_offline_tweets
|
||||
|
||||
import datetime
|
||||
import textwrap
|
||||
|
||||
@event('init')
|
||||
def setup(ctx, e):
|
||||
# start the offline tweet stream
|
||||
start_offline_tweets('data/batatweets.txt', 'chirp', time_factor=10000)
|
||||
|
||||
@event('chirp')
|
||||
def tweet(ctx, e):
|
||||
# we receive a tweet
|
||||
tweet = e.data
|
||||
|
||||
# parse date
|
||||
time = datetime.datetime.strptime(tweet['created_at'], '%a %b %d %H:%M:%S %z %Y')
|
||||
|
||||
# nicify text
|
||||
text = textwrap.fill(tweet['text'],initial_indent=' ', subsequent_indent=' ')
|
||||
|
||||
# generate output
|
||||
output = "[{}] {} (@{}):\n{}".format(time, tweet['user']['name'], tweet['user']['screen_name'], text)
|
||||
emit('tweet', output)
|
||||
53
demos/wordcloud.py
Normal file
53
demos/wordcloud.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from eca import *
|
||||
from eca.generators import start_offline_tweets
|
||||
|
||||
import datetime
|
||||
import textwrap
|
||||
import pprint
|
||||
import re
|
||||
|
||||
# This function will be called to set up the HTTP server
|
||||
def add_request_handlers(httpd):
|
||||
# use the library content from the template_static dir instead of our own
|
||||
# this is a bit finicky, since execution now depends on a proper working directory.
|
||||
httpd.add_content('/lib/', 'template_static/lib')
|
||||
httpd.add_content('/style/', 'template_static/style')
|
||||
|
||||
@event('init')
|
||||
def setup(ctx, e):
|
||||
# start the offline tweet stream
|
||||
start_offline_tweets('data/bata_2014.txt', 'chirp', time_factor=100000)
|
||||
ctx.words = {}
|
||||
|
||||
# simple word splitter
|
||||
pattern = re.compile('\W+')
|
||||
|
||||
# sample stopword list, needs to be much more sophisticated
|
||||
stopwords = ['het', 'een', 'aan', 'zijn', 'http', 'www', 'com', 'ben', 'jij']
|
||||
|
||||
def words(message):
|
||||
result = pattern.split(message)
|
||||
result = map(lambda w: w.lower(), result)
|
||||
result = filter(lambda w: w not in stopwords, result)
|
||||
result = filter(lambda w: len(w) > 2, result)
|
||||
return result
|
||||
|
||||
@event('chirp')
|
||||
def tweet(ctx, e):
|
||||
# we receive a tweet
|
||||
tweet = e.data
|
||||
|
||||
for w in words(tweet['text']):
|
||||
emit('word', {
|
||||
'action': 'add',
|
||||
'value': (w, 1)
|
||||
})
|
||||
emit('taart', {
|
||||
'action': 'add',
|
||||
'value': (str(w[0]), 1)
|
||||
})
|
||||
emit('balk', {
|
||||
'action': 'add',
|
||||
'value': (str(w[0]), 1)
|
||||
})
|
||||
|
||||
40
demos/wordcloud_static/index.html
Normal file
40
demos/wordcloud_static/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Neca Test</title>
|
||||
<link rel="stylesheet" href="/style/layout.css"/>
|
||||
<link rel="stylesheet" href="/style/theme.css"/>
|
||||
<link rel="stylesheet" href="/style/wordcloud.css"/>
|
||||
<script src="/lib/jquery-2.1.1.min.js"></script>
|
||||
<script src="/lib/jquery.flot.min.js"></script>
|
||||
<script src="/lib/jquery.flot.categories.min.js"></script>
|
||||
<script src="/lib/jquery.flot.pie.js"></script>
|
||||
<script src="/lib/core.js"></script>
|
||||
<script src="/lib/charts.js"></script>
|
||||
<script src="/lib/jqcloud-1.0.4.js"></script>
|
||||
<script src="/lib/wordcloud.js"></script>
|
||||
<script src="/lib/tweets.js"></script>
|
||||
</head>
|
||||
<body class="container_12">
|
||||
<h1>Word Cloud Dashboard</h1>
|
||||
|
||||
<div id="wolk" class="grid_12 vert_4"></div>
|
||||
<div id="taart" class="grid_6 vert_4"></div>
|
||||
<div id="balk" class="grid_6 vert_4"></div>
|
||||
|
||||
<script>
|
||||
block('#wolk').wordcloud({
|
||||
filter_function: function(cat, val, max) {
|
||||
return val >= 3; // do not display words seen less than 3 times
|
||||
}
|
||||
});
|
||||
events.connect('word', '#wolk');
|
||||
|
||||
block('#taart').piechart();
|
||||
events.connect('taart', '#taart');
|
||||
|
||||
block('#balk').barchart();
|
||||
events.connect('balk', '#balk');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
55
devjan.py
Normal file
55
devjan.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from eca import *
|
||||
|
||||
import random
|
||||
|
||||
## You might have to update the root path to point to the correct path
|
||||
## (by default, it points to <rules>_static)
|
||||
# root_content_path = 'template_static'
|
||||
|
||||
|
||||
# binds the 'setup' function as the action for the 'init' event
|
||||
# the action will be called with the context and the event
|
||||
@event('init')
|
||||
def setup(ctx, e):
|
||||
ctx.count = 0
|
||||
fire('sample', {'previous': 0.0})
|
||||
|
||||
|
||||
# define a normal Python function
|
||||
def clip(lower, value, upper):
|
||||
return max(lower, min(value, upper))
|
||||
|
||||
@event('sample')
|
||||
def generate_sample(ctx, e):
|
||||
ctx.count += 1
|
||||
if ctx.count % 50 == 0:
|
||||
emit('debug', {'text': 'Log message #'+str(ctx.count)+'!'})
|
||||
emit('barsample', {'action': 'reset'})
|
||||
|
||||
# base sample on previous one
|
||||
|
||||
sample = random.uniform(+5.0, -5.0)
|
||||
|
||||
li = int(random.uniform(+5.0,0))+1
|
||||
l = 'cat'+str(li)
|
||||
v = random.uniform(+10.0,0)
|
||||
|
||||
# emit to outside world
|
||||
# emit('linesample',{
|
||||
# 'action': 'add',
|
||||
# 'series' : 'lineA',
|
||||
# 'value': [[ctx.count,random.uniform(+10.0,0)]]
|
||||
# })
|
||||
# emit('linesample',{
|
||||
# 'action': 'add',
|
||||
# 'series' : 'lineB',
|
||||
# 'value': [[ctx.count,random.uniform(+10.0,0)]]
|
||||
# })
|
||||
emit('barsample',{ 'action': 'set', 'series': 'serie'+str(int(random.uniform(1,4))), 'value': [l,v] })
|
||||
emit('wordsample',{
|
||||
'action': 'add',
|
||||
'value': [l,v]
|
||||
})
|
||||
# chain event
|
||||
fire('sample', {'previous': sample}, delay=0.05)
|
||||
|
||||
100
devjan_static/index.html
Normal file
100
devjan_static/index.html
Normal file
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Neca Test</title>
|
||||
<link rel="stylesheet" href="/style/layout.css"/>
|
||||
<link rel="stylesheet" href="/style/theme.css"/>
|
||||
<script src="/lib/jquery-2.1.1.min.js"></script>
|
||||
<script src="/lib/jquery.flot.min.js"></script>
|
||||
<script src="/lib/jquery.flot.categories.min.js"></script>
|
||||
<script src="/lib/jquery.flot.pie.js"></script>
|
||||
<script src="/lib/events.js"></script>
|
||||
<script src="/lib/blocks.js"></script>
|
||||
<script src="/lib/charts.js"></script>
|
||||
<script src="/lib/piecharts.js"></script>
|
||||
<script src="/lib/barcharts.js"></script>
|
||||
<script src="/lib/log.js"></script>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="lib/jqcloud.css" />
|
||||
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.js"></script>
|
||||
<script type="text/javascript" src="lib/jqcloud-1.0.4.js"></script>
|
||||
<script src="/lib/wordcloud.js"></script>
|
||||
|
||||
</head>
|
||||
<body class="container_12">
|
||||
<h1>ECA Dashboard Template</h1>
|
||||
|
||||
<div class="grid_6 vert_4">
|
||||
<p>This is the dashboard template file. The easiest way to get started is to think up a simple name (let's say we take 'dashboard'). Now copy <code>template.py</code> to <code>{name}.py</code> start a new module (so that's <code>dashboard.py</code>) and copy <code>template_static</code> to <code>{name}_static</code>.
|
||||
<p>Now you can run the new project with: <pre>python neca.py -s {name}.py</pre>
|
||||
<p>Further documentation on the ECA system can be found at <a href="https://github.com/utwente-db/eca/wiki">github.com/utwente-db/eca/wiki</a>, and demos can be found in the <code>demos/</code> directory.
|
||||
</div>
|
||||
<div class="grid_6 vert_4">
|
||||
<p>In the sample <code>template.py</code> (which comes with the dashboard you're looking at right now), you will find the rules that power this example.
|
||||
<p>Rules are written in <a href="https://www.python.org/">Python</a> and work as follows:
|
||||
<pre>@event("foo")
|
||||
def action(context, event):
|
||||
print("Event " + event.name + "!")
|
||||
</pre>
|
||||
The <code>@event</code> part tells the system to fire the action whenever the event 'foo' occurs. The <code>def action(context, event):</code> part defines a new action that takes two arguments: the context and the event. The rest of the code is the action body.
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
<div class="grid_4">
|
||||
<p>The graph to the right is continuously filled with data generated by the rules.
|
||||
<p>In <code>template.py</code> you can see that an event called 'sample' is fired again and again to create new data points for the graph.
|
||||
<p>These points are then sent to the browser with:
|
||||
<pre>emit('sample',{
|
||||
'action': 'add',
|
||||
'value': sample
|
||||
})</pre>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="bargraph" class="grid_8 vert_4"></div>
|
||||
|
||||
<script>
|
||||
// create a barchart chart block
|
||||
block('#bargraph').barchart({
|
||||
bar_options:
|
||||
{
|
||||
series: {
|
||||
bar: { show: true }
|
||||
},
|
||||
bars: {
|
||||
align: "center",
|
||||
barWidth: 0.5
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// connect sample event to graph
|
||||
events.connect('barsample', '#bargraph');
|
||||
</script>
|
||||
|
||||
<div id="wordcloud" class="grid_8 vert_4"></div>
|
||||
|
||||
<script>
|
||||
// create a wordcloud block
|
||||
block('#wordcloud').wordcloud({
|
||||
word_options:
|
||||
{
|
||||
series: {
|
||||
pie: { show: true }
|
||||
},
|
||||
legend: { show: false }
|
||||
}
|
||||
});
|
||||
|
||||
// connect sample event to graph
|
||||
events.connect('wordsample', '#wordcloud');
|
||||
</script>
|
||||
|
||||
|
||||
// <div id="my_favorite_latin_words" class="grid_12 vert_8"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
108
devjan_static/lib/barcharts.js
Normal file
108
devjan_static/lib/barcharts.js
Normal file
@@ -0,0 +1,108 @@
|
||||
(function($, block) {
|
||||
|
||||
// a simple barchart example
|
||||
block.fn.barchart = function(config) {
|
||||
var options = $.extend({
|
||||
series : { "serie1":{
|
||||
data: {"January": 10, "February": 8, "March": 4, "April": 13, "May": 20, "June": 9},
|
||||
label: "serie 1",
|
||||
bars: {
|
||||
show: true,
|
||||
barWidth: 0.2,
|
||||
align: "left"
|
||||
}
|
||||
|
||||
}, "serie2":{
|
||||
data: {"January": 10, "February": 8, "March": 4, "April": 13, "May": 20, "June": 9},
|
||||
label: "series 2",
|
||||
bars: {
|
||||
show: true,
|
||||
barWidth: 0.2,
|
||||
align: "center"
|
||||
}
|
||||
}, "serie3":{
|
||||
data: {"January": 10, "February": 8, "March": 4, "April": 13, "May": 20, "June": 9},
|
||||
label: "series 3",
|
||||
bars: {
|
||||
show: true,
|
||||
barWidth: 0.2,
|
||||
align: "right"
|
||||
}
|
||||
}}
|
||||
}, config);
|
||||
|
||||
var bar_init = {
|
||||
xaxis: {
|
||||
mode: "categories",
|
||||
tickLength: 0
|
||||
}
|
||||
}
|
||||
|
||||
var bardata_series = options.series;
|
||||
|
||||
var translate_bar = function() {
|
||||
var result = [];
|
||||
for(var k in bardata_series) {
|
||||
if (bardata_series.hasOwnProperty(k)) {
|
||||
var newserie = jQuery.extend({}, bardata_series[k]);
|
||||
var newdata = [];
|
||||
var data = newserie.data;
|
||||
for(var l in data) {
|
||||
if (data.hasOwnProperty(l)) {
|
||||
newdata.push([l,data[l]]);
|
||||
}
|
||||
}
|
||||
newserie.data = newdata;
|
||||
result.push(newserie);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var plot = $.plot(this.$element, translate_bar(), bar_init);
|
||||
|
||||
var addbar = function(serie_label, category, value) {
|
||||
var data = bardata_series[serie_label].data;
|
||||
if (data.hasOwnProperty(category))
|
||||
data[category] = (data[category] + value);
|
||||
else
|
||||
data[category] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var setbar = function(serie_label, category, value) {
|
||||
var data = bardata_series[serie_label].data;
|
||||
data[category] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var redraw = function() {
|
||||
plot.setData(translate_bar());
|
||||
plot.setupGrid();
|
||||
plot.draw();
|
||||
}
|
||||
|
||||
var reset = function() {
|
||||
for(var k in bardata_series) {
|
||||
if (bardata_series.hasOwnProperty(k)) {
|
||||
bardata_series[k].data = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'set': function(e, message) {
|
||||
setbar(message.series,message.value[0],message.value[1]);
|
||||
},
|
||||
'add': function(e, message) {
|
||||
addbar(message.series,message.value[0],message.value[1]);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
})(jQuery, block);
|
||||
68
devjan_static/lib/blocks.js
Normal file
68
devjan_static/lib/blocks.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
** Blocks plugin allows for quick creation of new block types.
|
||||
*/
|
||||
(function($) {
|
||||
var ConstructionState = function($element) {
|
||||
this.$element = $element;
|
||||
|
||||
// transfer all block constructors to scope
|
||||
for(var b in block.fn) {
|
||||
// prevent overrides
|
||||
if(!(b in this)) {
|
||||
// reference block type in this object
|
||||
this[b] = block.fn[b];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ConstructionState.prototype.actions = function(actions_or_def, def) {
|
||||
// handle function overloading
|
||||
if(typeof actions_or_def == 'function') {
|
||||
def = actions_or_def;
|
||||
actions = {};
|
||||
} else {
|
||||
actions = actions_or_def;
|
||||
}
|
||||
|
||||
// default actionless handler
|
||||
if(typeof def == 'undefined') {
|
||||
def = function(e, message) {
|
||||
console.error("Received actionless server event." +
|
||||
" Did you forget to set an action field?");
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch all incoming server events
|
||||
this.$element.on('server-event', function(e, message) {
|
||||
if(!('action' in message)) {
|
||||
$(this).trigger('_default.server-event', [message]);
|
||||
} else {
|
||||
$(this).trigger(message.action+'.server-event', [message]);
|
||||
}
|
||||
});
|
||||
|
||||
// bind all actions
|
||||
this.$element.on('_default.server-event', def);
|
||||
|
||||
for(var k in actions) {
|
||||
this.$element.on(k+'.server-event', actions[k]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
block = function(elements) {
|
||||
// allow passing of selectors, jquery objects and DOM nodes
|
||||
var $element = $(elements);
|
||||
|
||||
// actual work
|
||||
if($element.length != 1) {
|
||||
console.error("Must have one element to create block for." +
|
||||
" Was given: '",elements,"'");
|
||||
return;
|
||||
}
|
||||
|
||||
return new ConstructionState($element);
|
||||
}
|
||||
|
||||
block.fn = {};
|
||||
})(jQuery);
|
||||
78
devjan_static/lib/charts.js
Normal file
78
devjan_static/lib/charts.js
Normal file
@@ -0,0 +1,78 @@
|
||||
(function($, block) {
|
||||
|
||||
// a simple rolling chart with memory
|
||||
block.fn.rolling_chart = function(config) {
|
||||
var options = $.extend({
|
||||
memory: 100,
|
||||
series: { serie : {label:"serie", color:'black'} }
|
||||
}, config);
|
||||
|
||||
var handle_data = function(values) {
|
||||
var result = [];
|
||||
|
||||
for(var i in values) {
|
||||
result.push([i, values[i]]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
var xo = { series: {
|
||||
lines: { show: true },
|
||||
points: {
|
||||
radius: 3,
|
||||
show: true,
|
||||
fill: true
|
||||
}
|
||||
}};
|
||||
|
||||
var plot = $.plot(this.$element, [] , {});
|
||||
|
||||
var reset = function() {
|
||||
var result = options.series;
|
||||
for(var k in result) {
|
||||
if (result.hasOwnProperty(k)) {
|
||||
result[k].databuffer = [];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var plot_series = reset();
|
||||
|
||||
var add_to_serie = function(skey,value) {
|
||||
var databuffer = plot_series[skey].databuffer;
|
||||
if(databuffer.length > options.memory) {
|
||||
plot_series[skey].databuffer = databuffer.slice(1);
|
||||
}
|
||||
databuffer.push(value);
|
||||
}
|
||||
|
||||
var redraw = function(serie_value) {
|
||||
var plot_current = [];
|
||||
var mykeys = Object.keys(plot_series);
|
||||
for(var mykey in mykeys) {
|
||||
var skey = mykeys[mykey];
|
||||
var serie = plot_series[skey];
|
||||
// serie['databuffer'].push(serie_value[skey]);
|
||||
add_to_serie(skey,serie_value[skey]);
|
||||
serie['data'] = handle_data(serie['databuffer']);
|
||||
plot_current.push(serie);
|
||||
}
|
||||
plot.setData(plot_current);
|
||||
plot.setupGrid();
|
||||
plot.draw();
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'add': function(e, message) {
|
||||
redraw(message.value);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
plot_series = reset();
|
||||
}
|
||||
});
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
})(jQuery, block);
|
||||
38
devjan_static/lib/events.js
Normal file
38
devjan_static/lib/events.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Event stream handling.
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/API/EventSource for a more
|
||||
comprehensive explanation.
|
||||
*/
|
||||
|
||||
events = {};
|
||||
|
||||
(function($, exports) {
|
||||
var e = new EventSource('/events');
|
||||
|
||||
exports.connect = function(name, elements) {
|
||||
// wrap to allow selector, jQuery object and DOM nodes
|
||||
var $elements = $(elements);
|
||||
|
||||
// add listener that triggers events in DOM
|
||||
this.listen(name, function(message) {
|
||||
$elements.trigger('server-event', [message]);
|
||||
});
|
||||
};
|
||||
|
||||
exports.listen = function(name, callback) {
|
||||
// add event listener to event stream
|
||||
e.addEventListener(name, function(m) {
|
||||
try {
|
||||
var message = JSON.parse(m.data);
|
||||
} catch(err) {
|
||||
console.exception("Received malformed message: ",err);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(message);
|
||||
});
|
||||
};
|
||||
})(jQuery, events);
|
||||
|
||||
|
||||
232
devjan_static/lib/jqcloud-1.0.4.js
Normal file
232
devjan_static/lib/jqcloud-1.0.4.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/*!
|
||||
* jQCloud Plugin for jQuery
|
||||
*
|
||||
* Version 1.0.4
|
||||
*
|
||||
* Copyright 2011, Luca Ongaro
|
||||
* Licensed under the MIT license.
|
||||
*
|
||||
* Date: 2013-05-09 18:54:22 +0200
|
||||
*/
|
||||
|
||||
(function( $ ) {
|
||||
"use strict";
|
||||
$.fn.jQCloud = function(word_array, options) {
|
||||
// Reference to the container element
|
||||
var $this = this;
|
||||
// Namespace word ids to avoid collisions between multiple clouds
|
||||
var cloud_namespace = $this.attr('id') || Math.floor((Math.random()*1000000)).toString(36);
|
||||
|
||||
// Default options value
|
||||
var default_options = {
|
||||
width: $this.width(),
|
||||
height: $this.height(),
|
||||
center: {
|
||||
x: ((options && options.width) ? options.width : $this.width()) / 2.0,
|
||||
y: ((options && options.height) ? options.height : $this.height()) / 2.0
|
||||
},
|
||||
delayedMode: word_array.length > 50,
|
||||
shape: false, // It defaults to elliptic shape
|
||||
encodeURI: true,
|
||||
removeOverflowing: true
|
||||
};
|
||||
|
||||
options = $.extend(default_options, options || {});
|
||||
|
||||
// Add the "jqcloud" class to the container for easy CSS styling, set container width/height
|
||||
$this.addClass("jqcloud").width(options.width).height(options.height);
|
||||
|
||||
// Container's CSS position cannot be 'static'
|
||||
if ($this.css("position") === "static") {
|
||||
$this.css("position", "relative");
|
||||
}
|
||||
|
||||
var drawWordCloud = function() {
|
||||
// Helper function to test if an element overlaps others
|
||||
var hitTest = function(elem, other_elems) {
|
||||
// Pairwise overlap detection
|
||||
var overlapping = function(a, b) {
|
||||
if (Math.abs(2.0*a.offsetLeft + a.offsetWidth - 2.0*b.offsetLeft - b.offsetWidth) < a.offsetWidth + b.offsetWidth) {
|
||||
if (Math.abs(2.0*a.offsetTop + a.offsetHeight - 2.0*b.offsetTop - b.offsetHeight) < a.offsetHeight + b.offsetHeight) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
var i = 0;
|
||||
// Check elements for overlap one by one, stop and return false as soon as an overlap is found
|
||||
for(i = 0; i < other_elems.length; i++) {
|
||||
if (overlapping(elem, other_elems[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Make sure every weight is a number before sorting
|
||||
for (var i = 0; i < word_array.length; i++) {
|
||||
word_array[i].weight = parseFloat(word_array[i].weight, 10);
|
||||
}
|
||||
|
||||
// Sort word_array from the word with the highest weight to the one with the lowest
|
||||
word_array.sort(function(a, b) { if (a.weight < b.weight) {return 1;} else if (a.weight > b.weight) {return -1;} else {return 0;} });
|
||||
|
||||
var step = (options.shape === "rectangular") ? 18.0 : 2.0,
|
||||
already_placed_words = [],
|
||||
aspect_ratio = options.width / options.height;
|
||||
|
||||
// Function to draw a word, by moving it in spiral until it finds a suitable empty place. This will be iterated on each word.
|
||||
var drawOneWord = function(index, word) {
|
||||
// Define the ID attribute of the span that will wrap the word, and the associated jQuery selector string
|
||||
var word_id = cloud_namespace + "_word_" + index,
|
||||
word_selector = "#" + word_id,
|
||||
angle = 6.28 * Math.random(),
|
||||
radius = 0.0,
|
||||
|
||||
// Only used if option.shape == 'rectangular'
|
||||
steps_in_direction = 0.0,
|
||||
quarter_turns = 0.0,
|
||||
|
||||
weight = 5,
|
||||
custom_class = "",
|
||||
inner_html = "",
|
||||
word_span;
|
||||
|
||||
// Extend word html options with defaults
|
||||
word.html = $.extend(word.html, {id: word_id});
|
||||
|
||||
// If custom class was specified, put them into a variable and remove it from html attrs, to avoid overwriting classes set by jQCloud
|
||||
if (word.html && word.html["class"]) {
|
||||
custom_class = word.html["class"];
|
||||
delete word.html["class"];
|
||||
}
|
||||
|
||||
// Check if min(weight) > max(weight) otherwise use default
|
||||
if (word_array[0].weight > word_array[word_array.length - 1].weight) {
|
||||
// Linearly map the original weight to a discrete scale from 1 to 10
|
||||
weight = Math.round((word.weight - word_array[word_array.length - 1].weight) /
|
||||
(word_array[0].weight - word_array[word_array.length - 1].weight) * 9.0) + 1;
|
||||
}
|
||||
word_span = $('<span>').attr(word.html).addClass('w' + weight + " " + custom_class);
|
||||
|
||||
// Append link if word.url attribute was set
|
||||
if (word.link) {
|
||||
// If link is a string, then use it as the link href
|
||||
if (typeof word.link === "string") {
|
||||
word.link = {href: word.link};
|
||||
}
|
||||
|
||||
// Extend link html options with defaults
|
||||
if ( options.encodeURI ) {
|
||||
word.link = $.extend(word.link, { href: encodeURI(word.link.href).replace(/'/g, "%27") });
|
||||
}
|
||||
|
||||
inner_html = $('<a>').attr(word.link).text(word.text);
|
||||
} else {
|
||||
inner_html = word.text;
|
||||
}
|
||||
word_span.append(inner_html);
|
||||
|
||||
// Bind handlers to words
|
||||
if (!!word.handlers) {
|
||||
for (var prop in word.handlers) {
|
||||
if (word.handlers.hasOwnProperty(prop) && typeof word.handlers[prop] === 'function') {
|
||||
$(word_span).bind(prop, word.handlers[prop]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this.append(word_span);
|
||||
|
||||
var width = word_span.width(),
|
||||
height = word_span.height(),
|
||||
left = options.center.x - width / 2.0,
|
||||
top = options.center.y - height / 2.0;
|
||||
|
||||
// Save a reference to the style property, for better performance
|
||||
var word_style = word_span[0].style;
|
||||
word_style.position = "absolute";
|
||||
word_style.left = left + "px";
|
||||
word_style.top = top + "px";
|
||||
|
||||
while(hitTest(word_span[0], already_placed_words)) {
|
||||
// option shape is 'rectangular' so move the word in a rectangular spiral
|
||||
if (options.shape === "rectangular") {
|
||||
steps_in_direction++;
|
||||
if (steps_in_direction * step > (1 + Math.floor(quarter_turns / 2.0)) * step * ((quarter_turns % 4 % 2) === 0 ? 1 : aspect_ratio)) {
|
||||
steps_in_direction = 0.0;
|
||||
quarter_turns++;
|
||||
}
|
||||
switch(quarter_turns % 4) {
|
||||
case 1:
|
||||
left += step * aspect_ratio + Math.random() * 2.0;
|
||||
break;
|
||||
case 2:
|
||||
top -= step + Math.random() * 2.0;
|
||||
break;
|
||||
case 3:
|
||||
left -= step * aspect_ratio + Math.random() * 2.0;
|
||||
break;
|
||||
case 0:
|
||||
top += step + Math.random() * 2.0;
|
||||
break;
|
||||
}
|
||||
} else { // Default settings: elliptic spiral shape
|
||||
radius += step;
|
||||
angle += (index % 2 === 0 ? 1 : -1)*step;
|
||||
|
||||
left = options.center.x - (width / 2.0) + (radius*Math.cos(angle)) * aspect_ratio;
|
||||
top = options.center.y + radius*Math.sin(angle) - (height / 2.0);
|
||||
}
|
||||
word_style.left = left + "px";
|
||||
word_style.top = top + "px";
|
||||
}
|
||||
|
||||
// Don't render word if part of it would be outside the container
|
||||
if (options.removeOverflowing && (left < 0 || top < 0 || (left + width) > options.width || (top + height) > options.height)) {
|
||||
word_span.remove()
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
already_placed_words.push(word_span[0]);
|
||||
|
||||
// Invoke callback if existing
|
||||
if ($.isFunction(word.afterWordRender)) {
|
||||
word.afterWordRender.call(word_span);
|
||||
}
|
||||
};
|
||||
|
||||
var drawOneWordDelayed = function(index) {
|
||||
index = index || 0;
|
||||
if (!$this.is(':visible')) { // if not visible then do not attempt to draw
|
||||
setTimeout(function(){drawOneWordDelayed(index);},10);
|
||||
return;
|
||||
}
|
||||
if (index < word_array.length) {
|
||||
drawOneWord(index, word_array[index]);
|
||||
setTimeout(function(){drawOneWordDelayed(index + 1);}, 10);
|
||||
} else {
|
||||
if ($.isFunction(options.afterCloudRender)) {
|
||||
options.afterCloudRender.call($this);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Iterate drawOneWord on every word. The way the iteration is done depends on the drawing mode (delayedMode is true or false)
|
||||
if (options.delayedMode){
|
||||
drawOneWordDelayed();
|
||||
}
|
||||
else {
|
||||
$.each(word_array, drawOneWord);
|
||||
if ($.isFunction(options.afterCloudRender)) {
|
||||
options.afterCloudRender.call($this);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Delay execution so that the browser can render the page before the computatively intensive word cloud drawing
|
||||
setTimeout(function(){drawWordCloud();}, 10);
|
||||
return $this;
|
||||
};
|
||||
})(jQuery);
|
||||
49
devjan_static/lib/jqcloud.css
Normal file
49
devjan_static/lib/jqcloud.css
Normal file
@@ -0,0 +1,49 @@
|
||||
/* fonts */
|
||||
|
||||
div.jqcloud {
|
||||
font-family: "Helvetica", "Arial", sans-serif;
|
||||
font-size: 10px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
div.jqcloud a {
|
||||
font-size: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
div.jqcloud span.w10 { font-size: 550%; }
|
||||
div.jqcloud span.w9 { font-size: 500%; }
|
||||
div.jqcloud span.w8 { font-size: 450%; }
|
||||
div.jqcloud span.w7 { font-size: 400%; }
|
||||
div.jqcloud span.w6 { font-size: 350%; }
|
||||
div.jqcloud span.w5 { font-size: 300%; }
|
||||
div.jqcloud span.w4 { font-size: 250%; }
|
||||
div.jqcloud span.w3 { font-size: 200%; }
|
||||
div.jqcloud span.w2 { font-size: 150%; }
|
||||
div.jqcloud span.w1 { font-size: 100%; }
|
||||
|
||||
/* colors */
|
||||
|
||||
div.jqcloud { color: #09f; }
|
||||
div.jqcloud a { color: inherit; }
|
||||
div.jqcloud a:hover { color: #0df; }
|
||||
div.jqcloud a:hover { color: #0cf; }
|
||||
div.jqcloud span.w10 { color: #0cf; }
|
||||
div.jqcloud span.w9 { color: #0cf; }
|
||||
div.jqcloud span.w8 { color: #0cf; }
|
||||
div.jqcloud span.w7 { color: #39d; }
|
||||
div.jqcloud span.w6 { color: #90c5f0; }
|
||||
div.jqcloud span.w5 { color: #90a0dd; }
|
||||
div.jqcloud span.w4 { color: #90c5f0; }
|
||||
div.jqcloud span.w3 { color: #a0ddff; }
|
||||
div.jqcloud span.w2 { color: #99ccee; }
|
||||
div.jqcloud span.w1 { color: #aab5f0; }
|
||||
|
||||
/* layout */
|
||||
|
||||
div.jqcloud {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div.jqcloud span { padding: 0; }
|
||||
4
devjan_static/lib/jquery-2.1.1.min.js
vendored
Normal file
4
devjan_static/lib/jquery-2.1.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
devjan_static/lib/jquery.flot.min.js
vendored
Normal file
8
devjan_static/lib/jquery.flot.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
820
devjan_static/lib/jquery.flot.pie.js
Normal file
820
devjan_static/lib/jquery.flot.pie.js
Normal file
@@ -0,0 +1,820 @@
|
||||
/* Flot plugin for rendering pie charts.
|
||||
|
||||
Copyright (c) 2007-2014 IOLA and Ole Laursen.
|
||||
Licensed under the MIT license.
|
||||
|
||||
The plugin assumes that each series has a single data value, and that each
|
||||
value is a positive integer or zero. Negative numbers don't make sense for a
|
||||
pie chart, and have unpredictable results. The values do NOT need to be
|
||||
passed in as percentages; the plugin will calculate the total and per-slice
|
||||
percentages internally.
|
||||
|
||||
* Created by Brian Medendorp
|
||||
|
||||
* Updated with contributions from btburnett3, Anthony Aragues and Xavi Ivars
|
||||
|
||||
The plugin supports these options:
|
||||
|
||||
series: {
|
||||
pie: {
|
||||
show: true/false
|
||||
radius: 0-1 for percentage of fullsize, or a specified pixel length, or 'auto'
|
||||
innerRadius: 0-1 for percentage of fullsize or a specified pixel length, for creating a donut effect
|
||||
startAngle: 0-2 factor of PI used for starting angle (in radians) i.e 3/2 starts at the top, 0 and 2 have the same result
|
||||
tilt: 0-1 for percentage to tilt the pie, where 1 is no tilt, and 0 is completely flat (nothing will show)
|
||||
offset: {
|
||||
top: integer value to move the pie up or down
|
||||
left: integer value to move the pie left or right, or 'auto'
|
||||
},
|
||||
stroke: {
|
||||
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#FFF')
|
||||
width: integer pixel width of the stroke
|
||||
},
|
||||
label: {
|
||||
show: true/false, or 'auto'
|
||||
formatter: a user-defined function that modifies the text/style of the label text
|
||||
radius: 0-1 for percentage of fullsize, or a specified pixel length
|
||||
background: {
|
||||
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#000')
|
||||
opacity: 0-1
|
||||
},
|
||||
threshold: 0-1 for the percentage value at which to hide labels (if they're too small)
|
||||
},
|
||||
combine: {
|
||||
threshold: 0-1 for the percentage value at which to combine slices (if they're too small)
|
||||
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#CCC'), if null, the plugin will automatically use the color of the first slice to be combined
|
||||
label: any text value of what the combined slice should be labeled
|
||||
}
|
||||
highlight: {
|
||||
opacity: 0-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
More detail and specific examples can be found in the included HTML file.
|
||||
|
||||
*/
|
||||
|
||||
(function($) {
|
||||
|
||||
// Maximum redraw attempts when fitting labels within the plot
|
||||
|
||||
var REDRAW_ATTEMPTS = 10;
|
||||
|
||||
// Factor by which to shrink the pie when fitting labels within the plot
|
||||
|
||||
var REDRAW_SHRINK = 0.95;
|
||||
|
||||
function init(plot) {
|
||||
|
||||
var canvas = null,
|
||||
target = null,
|
||||
options = null,
|
||||
maxRadius = null,
|
||||
centerLeft = null,
|
||||
centerTop = null,
|
||||
processed = false,
|
||||
ctx = null;
|
||||
|
||||
// interactive variables
|
||||
|
||||
var highlights = [];
|
||||
|
||||
// add hook to determine if pie plugin in enabled, and then perform necessary operations
|
||||
|
||||
plot.hooks.processOptions.push(function(plot, options) {
|
||||
if (options.series.pie.show) {
|
||||
|
||||
options.grid.show = false;
|
||||
|
||||
// set labels.show
|
||||
|
||||
if (options.series.pie.label.show == "auto") {
|
||||
if (options.legend.show) {
|
||||
options.series.pie.label.show = false;
|
||||
} else {
|
||||
options.series.pie.label.show = true;
|
||||
}
|
||||
}
|
||||
|
||||
// set radius
|
||||
|
||||
if (options.series.pie.radius == "auto") {
|
||||
if (options.series.pie.label.show) {
|
||||
options.series.pie.radius = 3/4;
|
||||
} else {
|
||||
options.series.pie.radius = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ensure sane tilt
|
||||
|
||||
if (options.series.pie.tilt > 1) {
|
||||
options.series.pie.tilt = 1;
|
||||
} else if (options.series.pie.tilt < 0) {
|
||||
options.series.pie.tilt = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
plot.hooks.bindEvents.push(function(plot, eventHolder) {
|
||||
var options = plot.getOptions();
|
||||
if (options.series.pie.show) {
|
||||
if (options.grid.hoverable) {
|
||||
eventHolder.unbind("mousemove").mousemove(onMouseMove);
|
||||
}
|
||||
if (options.grid.clickable) {
|
||||
eventHolder.unbind("click").click(onClick);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
plot.hooks.processDatapoints.push(function(plot, series, data, datapoints) {
|
||||
var options = plot.getOptions();
|
||||
if (options.series.pie.show) {
|
||||
processDatapoints(plot, series, data, datapoints);
|
||||
}
|
||||
});
|
||||
|
||||
plot.hooks.drawOverlay.push(function(plot, octx) {
|
||||
var options = plot.getOptions();
|
||||
if (options.series.pie.show) {
|
||||
drawOverlay(plot, octx);
|
||||
}
|
||||
});
|
||||
|
||||
plot.hooks.draw.push(function(plot, newCtx) {
|
||||
var options = plot.getOptions();
|
||||
if (options.series.pie.show) {
|
||||
draw(plot, newCtx);
|
||||
}
|
||||
});
|
||||
|
||||
function processDatapoints(plot, series, datapoints) {
|
||||
if (!processed) {
|
||||
processed = true;
|
||||
canvas = plot.getCanvas();
|
||||
target = $(canvas).parent();
|
||||
options = plot.getOptions();
|
||||
plot.setData(combine(plot.getData()));
|
||||
}
|
||||
}
|
||||
|
||||
function combine(data) {
|
||||
|
||||
var total = 0,
|
||||
combined = 0,
|
||||
numCombined = 0,
|
||||
color = options.series.pie.combine.color,
|
||||
newdata = [];
|
||||
|
||||
// Fix up the raw data from Flot, ensuring the data is numeric
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
|
||||
var value = data[i].data;
|
||||
|
||||
// If the data is an array, we'll assume that it's a standard
|
||||
// Flot x-y pair, and are concerned only with the second value.
|
||||
|
||||
// Note how we use the original array, rather than creating a
|
||||
// new one; this is more efficient and preserves any extra data
|
||||
// that the user may have stored in higher indexes.
|
||||
|
||||
if ($.isArray(value) && value.length == 1) {
|
||||
value = value[0];
|
||||
}
|
||||
|
||||
if ($.isArray(value)) {
|
||||
// Equivalent to $.isNumeric() but compatible with jQuery < 1.7
|
||||
if (!isNaN(parseFloat(value[1])) && isFinite(value[1])) {
|
||||
value[1] = +value[1];
|
||||
} else {
|
||||
value[1] = 0;
|
||||
}
|
||||
} else if (!isNaN(parseFloat(value)) && isFinite(value)) {
|
||||
value = [1, +value];
|
||||
} else {
|
||||
value = [1, 0];
|
||||
}
|
||||
|
||||
data[i].data = [value];
|
||||
}
|
||||
|
||||
// Sum up all the slices, so we can calculate percentages for each
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
total += data[i].data[0][1];
|
||||
}
|
||||
|
||||
// Count the number of slices with percentages below the combine
|
||||
// threshold; if it turns out to be just one, we won't combine.
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
var value = data[i].data[0][1];
|
||||
if (value / total <= options.series.pie.combine.threshold) {
|
||||
combined += value;
|
||||
numCombined++;
|
||||
if (!color) {
|
||||
color = data[i].color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
var value = data[i].data[0][1];
|
||||
if (numCombined < 2 || value / total > options.series.pie.combine.threshold) {
|
||||
newdata.push(
|
||||
$.extend(data[i], { /* extend to allow keeping all other original data values
|
||||
and using them e.g. in labelFormatter. */
|
||||
data: [[1, value]],
|
||||
color: data[i].color,
|
||||
label: data[i].label,
|
||||
angle: value * Math.PI * 2 / total,
|
||||
percent: value / (total / 100)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (numCombined > 1) {
|
||||
newdata.push({
|
||||
data: [[1, combined]],
|
||||
color: color,
|
||||
label: options.series.pie.combine.label,
|
||||
angle: combined * Math.PI * 2 / total,
|
||||
percent: combined / (total / 100)
|
||||
});
|
||||
}
|
||||
|
||||
return newdata;
|
||||
}
|
||||
|
||||
function draw(plot, newCtx) {
|
||||
|
||||
if (!target) {
|
||||
return; // if no series were passed
|
||||
}
|
||||
|
||||
var canvasWidth = plot.getPlaceholder().width(),
|
||||
canvasHeight = plot.getPlaceholder().height(),
|
||||
legendWidth = target.children().filter(".legend").children().width() || 0;
|
||||
|
||||
ctx = newCtx;
|
||||
|
||||
// WARNING: HACK! REWRITE THIS CODE AS SOON AS POSSIBLE!
|
||||
|
||||
// When combining smaller slices into an 'other' slice, we need to
|
||||
// add a new series. Since Flot gives plugins no way to modify the
|
||||
// list of series, the pie plugin uses a hack where the first call
|
||||
// to processDatapoints results in a call to setData with the new
|
||||
// list of series, then subsequent processDatapoints do nothing.
|
||||
|
||||
// The plugin-global 'processed' flag is used to control this hack;
|
||||
// it starts out false, and is set to true after the first call to
|
||||
// processDatapoints.
|
||||
|
||||
// Unfortunately this turns future setData calls into no-ops; they
|
||||
// call processDatapoints, the flag is true, and nothing happens.
|
||||
|
||||
// To fix this we'll set the flag back to false here in draw, when
|
||||
// all series have been processed, so the next sequence of calls to
|
||||
// processDatapoints once again starts out with a slice-combine.
|
||||
// This is really a hack; in 0.9 we need to give plugins a proper
|
||||
// way to modify series before any processing begins.
|
||||
|
||||
processed = false;
|
||||
|
||||
// calculate maximum radius and center point
|
||||
|
||||
maxRadius = Math.min(canvasWidth, canvasHeight / options.series.pie.tilt) / 2;
|
||||
centerTop = canvasHeight / 2 + options.series.pie.offset.top;
|
||||
centerLeft = canvasWidth / 2;
|
||||
|
||||
if (options.series.pie.offset.left == "auto") {
|
||||
if (options.legend.position.match("w")) {
|
||||
centerLeft += legendWidth / 2;
|
||||
} else {
|
||||
centerLeft -= legendWidth / 2;
|
||||
}
|
||||
if (centerLeft < maxRadius) {
|
||||
centerLeft = maxRadius;
|
||||
} else if (centerLeft > canvasWidth - maxRadius) {
|
||||
centerLeft = canvasWidth - maxRadius;
|
||||
}
|
||||
} else {
|
||||
centerLeft += options.series.pie.offset.left;
|
||||
}
|
||||
|
||||
var slices = plot.getData(),
|
||||
attempts = 0;
|
||||
|
||||
// Keep shrinking the pie's radius until drawPie returns true,
|
||||
// indicating that all the labels fit, or we try too many times.
|
||||
|
||||
do {
|
||||
if (attempts > 0) {
|
||||
maxRadius *= REDRAW_SHRINK;
|
||||
}
|
||||
attempts += 1;
|
||||
clear();
|
||||
if (options.series.pie.tilt <= 0.8) {
|
||||
drawShadow();
|
||||
}
|
||||
} while (!drawPie() && attempts < REDRAW_ATTEMPTS)
|
||||
|
||||
if (attempts >= REDRAW_ATTEMPTS) {
|
||||
clear();
|
||||
target.prepend("<div class='error'>Could not draw pie with labels contained inside canvas</div>");
|
||||
}
|
||||
|
||||
if (plot.setSeries && plot.insertLegend) {
|
||||
plot.setSeries(slices);
|
||||
plot.insertLegend();
|
||||
}
|
||||
|
||||
// we're actually done at this point, just defining internal functions at this point
|
||||
|
||||
function clear() {
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
target.children().filter(".pieLabel, .pieLabelBackground").remove();
|
||||
}
|
||||
|
||||
function drawShadow() {
|
||||
|
||||
var shadowLeft = options.series.pie.shadow.left;
|
||||
var shadowTop = options.series.pie.shadow.top;
|
||||
var edge = 10;
|
||||
var alpha = options.series.pie.shadow.alpha;
|
||||
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
|
||||
|
||||
if (radius >= canvasWidth / 2 - shadowLeft || radius * options.series.pie.tilt >= canvasHeight / 2 - shadowTop || radius <= edge) {
|
||||
return; // shadow would be outside canvas, so don't draw it
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(shadowLeft,shadowTop);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
// center and rotate to starting position
|
||||
|
||||
ctx.translate(centerLeft,centerTop);
|
||||
ctx.scale(1, options.series.pie.tilt);
|
||||
|
||||
//radius -= edge;
|
||||
|
||||
for (var i = 1; i <= edge; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, radius, 0, Math.PI * 2, false);
|
||||
ctx.fill();
|
||||
radius -= i;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPie() {
|
||||
|
||||
var startAngle = Math.PI * options.series.pie.startAngle;
|
||||
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
|
||||
|
||||
// center and rotate to starting position
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(centerLeft,centerTop);
|
||||
ctx.scale(1, options.series.pie.tilt);
|
||||
//ctx.rotate(startAngle); // start at top; -- This doesn't work properly in Opera
|
||||
|
||||
// draw slices
|
||||
|
||||
ctx.save();
|
||||
var currentAngle = startAngle;
|
||||
for (var i = 0; i < slices.length; ++i) {
|
||||
slices[i].startAngle = currentAngle;
|
||||
drawSlice(slices[i].angle, slices[i].color, true);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// draw slice outlines
|
||||
|
||||
if (options.series.pie.stroke.width > 0) {
|
||||
ctx.save();
|
||||
ctx.lineWidth = options.series.pie.stroke.width;
|
||||
currentAngle = startAngle;
|
||||
for (var i = 0; i < slices.length; ++i) {
|
||||
drawSlice(slices[i].angle, options.series.pie.stroke.color, false);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// draw donut hole
|
||||
|
||||
drawDonutHole(ctx);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Draw the labels, returning true if they fit within the plot
|
||||
|
||||
if (options.series.pie.label.show) {
|
||||
return drawLabels();
|
||||
} else return true;
|
||||
|
||||
function drawSlice(angle, color, fill) {
|
||||
|
||||
if (angle <= 0 || isNaN(angle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fill) {
|
||||
ctx.fillStyle = color;
|
||||
} else {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineJoin = "round";
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
if (Math.abs(angle - Math.PI * 2) > 0.000000001) {
|
||||
ctx.moveTo(0, 0); // Center of the pie
|
||||
}
|
||||
|
||||
//ctx.arc(0, 0, radius, 0, angle, false); // This doesn't work properly in Opera
|
||||
ctx.arc(0, 0, radius,currentAngle, currentAngle + angle / 2, false);
|
||||
ctx.arc(0, 0, radius,currentAngle + angle / 2, currentAngle + angle, false);
|
||||
ctx.closePath();
|
||||
//ctx.rotate(angle); // This doesn't work properly in Opera
|
||||
currentAngle += angle;
|
||||
|
||||
if (fill) {
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawLabels() {
|
||||
|
||||
var currentAngle = startAngle;
|
||||
var radius = options.series.pie.label.radius > 1 ? options.series.pie.label.radius : maxRadius * options.series.pie.label.radius;
|
||||
|
||||
for (var i = 0; i < slices.length; ++i) {
|
||||
if (slices[i].percent >= options.series.pie.label.threshold * 100) {
|
||||
if (!drawLabel(slices[i], currentAngle, i)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
currentAngle += slices[i].angle;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
function drawLabel(slice, startAngle, index) {
|
||||
|
||||
if (slice.data[0][1] == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// format label text
|
||||
|
||||
var lf = options.legend.labelFormatter, text, plf = options.series.pie.label.formatter;
|
||||
|
||||
if (lf) {
|
||||
text = lf(slice.label, slice);
|
||||
} else {
|
||||
text = slice.label;
|
||||
}
|
||||
|
||||
if (plf) {
|
||||
text = plf(text, slice);
|
||||
}
|
||||
|
||||
var halfAngle = ((startAngle + slice.angle) + startAngle) / 2;
|
||||
var x = centerLeft + Math.round(Math.cos(halfAngle) * radius);
|
||||
var y = centerTop + Math.round(Math.sin(halfAngle) * radius) * options.series.pie.tilt;
|
||||
|
||||
var html = "<span class='pieLabel' id='pieLabel" + index + "' style='position:absolute;top:" + y + "px;left:" + x + "px;'>" + text + "</span>";
|
||||
target.append(html);
|
||||
|
||||
var label = target.children("#pieLabel" + index);
|
||||
var labelTop = (y - label.height() / 2);
|
||||
var labelLeft = (x - label.width() / 2);
|
||||
|
||||
label.css("top", labelTop);
|
||||
label.css("left", labelLeft);
|
||||
|
||||
// check to make sure that the label is not outside the canvas
|
||||
|
||||
if (0 - labelTop > 0 || 0 - labelLeft > 0 || canvasHeight - (labelTop + label.height()) < 0 || canvasWidth - (labelLeft + label.width()) < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.series.pie.label.background.opacity != 0) {
|
||||
|
||||
// put in the transparent background separately to avoid blended labels and label boxes
|
||||
|
||||
var c = options.series.pie.label.background.color;
|
||||
|
||||
if (c == null) {
|
||||
c = slice.color;
|
||||
}
|
||||
|
||||
var pos = "top:" + labelTop + "px;left:" + labelLeft + "px;";
|
||||
$("<div class='pieLabelBackground' style='position:absolute;width:" + label.width() + "px;height:" + label.height() + "px;" + pos + "background-color:" + c + ";'></div>")
|
||||
.css("opacity", options.series.pie.label.background.opacity)
|
||||
.insertBefore(label);
|
||||
}
|
||||
|
||||
return true;
|
||||
} // end individual label function
|
||||
} // end drawLabels function
|
||||
} // end drawPie function
|
||||
} // end draw function
|
||||
|
||||
// Placed here because it needs to be accessed from multiple locations
|
||||
|
||||
function drawDonutHole(layer) {
|
||||
if (options.series.pie.innerRadius > 0) {
|
||||
|
||||
// subtract the center
|
||||
|
||||
layer.save();
|
||||
var innerRadius = options.series.pie.innerRadius > 1 ? options.series.pie.innerRadius : maxRadius * options.series.pie.innerRadius;
|
||||
layer.globalCompositeOperation = "destination-out"; // this does not work with excanvas, but it will fall back to using the stroke color
|
||||
layer.beginPath();
|
||||
layer.fillStyle = options.series.pie.stroke.color;
|
||||
layer.arc(0, 0, innerRadius, 0, Math.PI * 2, false);
|
||||
layer.fill();
|
||||
layer.closePath();
|
||||
layer.restore();
|
||||
|
||||
// add inner stroke
|
||||
|
||||
layer.save();
|
||||
layer.beginPath();
|
||||
layer.strokeStyle = options.series.pie.stroke.color;
|
||||
layer.arc(0, 0, innerRadius, 0, Math.PI * 2, false);
|
||||
layer.stroke();
|
||||
layer.closePath();
|
||||
layer.restore();
|
||||
|
||||
// TODO: add extra shadow inside hole (with a mask) if the pie is tilted.
|
||||
}
|
||||
}
|
||||
|
||||
//-- Additional Interactive related functions --
|
||||
|
||||
function isPointInPoly(poly, pt) {
|
||||
for(var c = false, i = -1, l = poly.length, j = l - 1; ++i < l; j = i)
|
||||
((poly[i][1] <= pt[1] && pt[1] < poly[j][1]) || (poly[j][1] <= pt[1] && pt[1]< poly[i][1]))
|
||||
&& (pt[0] < (poly[j][0] - poly[i][0]) * (pt[1] - poly[i][1]) / (poly[j][1] - poly[i][1]) + poly[i][0])
|
||||
&& (c = !c);
|
||||
return c;
|
||||
}
|
||||
|
||||
function findNearbySlice(mouseX, mouseY) {
|
||||
|
||||
var slices = plot.getData(),
|
||||
options = plot.getOptions(),
|
||||
radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius,
|
||||
x, y;
|
||||
|
||||
for (var i = 0; i < slices.length; ++i) {
|
||||
|
||||
var s = slices[i];
|
||||
|
||||
if (s.pie.show) {
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0); // Center of the pie
|
||||
//ctx.scale(1, options.series.pie.tilt); // this actually seems to break everything when here.
|
||||
ctx.arc(0, 0, radius, s.startAngle, s.startAngle + s.angle / 2, false);
|
||||
ctx.arc(0, 0, radius, s.startAngle + s.angle / 2, s.startAngle + s.angle, false);
|
||||
ctx.closePath();
|
||||
x = mouseX - centerLeft;
|
||||
y = mouseY - centerTop;
|
||||
|
||||
if (ctx.isPointInPath) {
|
||||
if (ctx.isPointInPath(mouseX - centerLeft, mouseY - centerTop)) {
|
||||
ctx.restore();
|
||||
return {
|
||||
datapoint: [s.percent, s.data],
|
||||
dataIndex: 0,
|
||||
series: s,
|
||||
seriesIndex: i
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
// excanvas for IE doesn;t support isPointInPath, this is a workaround.
|
||||
|
||||
var p1X = radius * Math.cos(s.startAngle),
|
||||
p1Y = radius * Math.sin(s.startAngle),
|
||||
p2X = radius * Math.cos(s.startAngle + s.angle / 4),
|
||||
p2Y = radius * Math.sin(s.startAngle + s.angle / 4),
|
||||
p3X = radius * Math.cos(s.startAngle + s.angle / 2),
|
||||
p3Y = radius * Math.sin(s.startAngle + s.angle / 2),
|
||||
p4X = radius * Math.cos(s.startAngle + s.angle / 1.5),
|
||||
p4Y = radius * Math.sin(s.startAngle + s.angle / 1.5),
|
||||
p5X = radius * Math.cos(s.startAngle + s.angle),
|
||||
p5Y = radius * Math.sin(s.startAngle + s.angle),
|
||||
arrPoly = [[0, 0], [p1X, p1Y], [p2X, p2Y], [p3X, p3Y], [p4X, p4Y], [p5X, p5Y]],
|
||||
arrPoint = [x, y];
|
||||
|
||||
// TODO: perhaps do some mathmatical trickery here with the Y-coordinate to compensate for pie tilt?
|
||||
|
||||
if (isPointInPoly(arrPoly, arrPoint)) {
|
||||
ctx.restore();
|
||||
return {
|
||||
datapoint: [s.percent, s.data],
|
||||
dataIndex: 0,
|
||||
series: s,
|
||||
seriesIndex: i
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
triggerClickHoverEvent("plothover", e);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
triggerClickHoverEvent("plotclick", e);
|
||||
}
|
||||
|
||||
// trigger click or hover event (they send the same parameters so we share their code)
|
||||
|
||||
function triggerClickHoverEvent(eventname, e) {
|
||||
|
||||
var offset = plot.offset();
|
||||
var canvasX = parseInt(e.pageX - offset.left);
|
||||
var canvasY = parseInt(e.pageY - offset.top);
|
||||
var item = findNearbySlice(canvasX, canvasY);
|
||||
|
||||
if (options.grid.autoHighlight) {
|
||||
|
||||
// clear auto-highlights
|
||||
|
||||
for (var i = 0; i < highlights.length; ++i) {
|
||||
var h = highlights[i];
|
||||
if (h.auto == eventname && !(item && h.series == item.series)) {
|
||||
unhighlight(h.series);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// highlight the slice
|
||||
|
||||
if (item) {
|
||||
highlight(item.series, eventname);
|
||||
}
|
||||
|
||||
// trigger any hover bind events
|
||||
|
||||
var pos = { pageX: e.pageX, pageY: e.pageY };
|
||||
target.trigger(eventname, [pos, item]);
|
||||
}
|
||||
|
||||
function highlight(s, auto) {
|
||||
//if (typeof s == "number") {
|
||||
// s = series[s];
|
||||
//}
|
||||
|
||||
var i = indexOfHighlight(s);
|
||||
|
||||
if (i == -1) {
|
||||
highlights.push({ series: s, auto: auto });
|
||||
plot.triggerRedrawOverlay();
|
||||
} else if (!auto) {
|
||||
highlights[i].auto = false;
|
||||
}
|
||||
}
|
||||
|
||||
function unhighlight(s) {
|
||||
if (s == null) {
|
||||
highlights = [];
|
||||
plot.triggerRedrawOverlay();
|
||||
}
|
||||
|
||||
//if (typeof s == "number") {
|
||||
// s = series[s];
|
||||
//}
|
||||
|
||||
var i = indexOfHighlight(s);
|
||||
|
||||
if (i != -1) {
|
||||
highlights.splice(i, 1);
|
||||
plot.triggerRedrawOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function indexOfHighlight(s) {
|
||||
for (var i = 0; i < highlights.length; ++i) {
|
||||
var h = highlights[i];
|
||||
if (h.series == s)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function drawOverlay(plot, octx) {
|
||||
|
||||
var options = plot.getOptions();
|
||||
|
||||
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
|
||||
|
||||
octx.save();
|
||||
octx.translate(centerLeft, centerTop);
|
||||
octx.scale(1, options.series.pie.tilt);
|
||||
|
||||
for (var i = 0; i < highlights.length; ++i) {
|
||||
drawHighlight(highlights[i].series);
|
||||
}
|
||||
|
||||
drawDonutHole(octx);
|
||||
|
||||
octx.restore();
|
||||
|
||||
function drawHighlight(series) {
|
||||
|
||||
if (series.angle <= 0 || isNaN(series.angle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
//octx.fillStyle = parseColor(options.series.pie.highlight.color).scale(null, null, null, options.series.pie.highlight.opacity).toString();
|
||||
octx.fillStyle = "rgba(255, 255, 255, " + options.series.pie.highlight.opacity + ")"; // this is temporary until we have access to parseColor
|
||||
octx.beginPath();
|
||||
if (Math.abs(series.angle - Math.PI * 2) > 0.000000001) {
|
||||
octx.moveTo(0, 0); // Center of the pie
|
||||
}
|
||||
octx.arc(0, 0, radius, series.startAngle, series.startAngle + series.angle / 2, false);
|
||||
octx.arc(0, 0, radius, series.startAngle + series.angle / 2, series.startAngle + series.angle, false);
|
||||
octx.closePath();
|
||||
octx.fill();
|
||||
}
|
||||
}
|
||||
} // end init (plugin body)
|
||||
|
||||
// define pie specific options and their default values
|
||||
|
||||
var options = {
|
||||
series: {
|
||||
pie: {
|
||||
show: false,
|
||||
radius: "auto", // actual radius of the visible pie (based on full calculated radius if <=1, or hard pixel value)
|
||||
innerRadius: 0, /* for donut */
|
||||
startAngle: 3/2,
|
||||
tilt: 1,
|
||||
shadow: {
|
||||
left: 5, // shadow left offset
|
||||
top: 15, // shadow top offset
|
||||
alpha: 0.02 // shadow alpha
|
||||
},
|
||||
offset: {
|
||||
top: 0,
|
||||
left: "auto"
|
||||
},
|
||||
stroke: {
|
||||
color: "#fff",
|
||||
width: 1
|
||||
},
|
||||
label: {
|
||||
show: "auto",
|
||||
formatter: function(label, slice) {
|
||||
return "<div style='font-size:x-small;text-align:center;padding:2px;color:" + slice.color + ";'>" + label + "<br/>" + Math.round(slice.percent) + "%</div>";
|
||||
}, // formatter function
|
||||
radius: 1, // radius at which to place the labels (based on full calculated radius if <=1, or hard pixel value)
|
||||
background: {
|
||||
color: null,
|
||||
opacity: 0
|
||||
},
|
||||
threshold: 0 // percentage at which to hide the label (i.e. the slice is too narrow)
|
||||
},
|
||||
combine: {
|
||||
threshold: -1, // percentage at which to combine little slices into one larger slice
|
||||
color: null, // color to give the new slice (auto-generated if null)
|
||||
label: "Other" // label to give the new slice
|
||||
},
|
||||
highlight: {
|
||||
//color: "#fff", // will add this functionality once parseColor is available
|
||||
opacity: 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$.plot.plugins.push({
|
||||
init: init,
|
||||
options: options,
|
||||
name: "pie",
|
||||
version: "1.1"
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
83
devjan_static/lib/linecharts.js
Normal file
83
devjan_static/lib/linecharts.js
Normal file
@@ -0,0 +1,83 @@
|
||||
(function($, block) {
|
||||
|
||||
// a simple linechart example
|
||||
block.fn.linechart = function(config) {
|
||||
var options = $.extend({
|
||||
line_series : ["default"],
|
||||
line_options : {
|
||||
series: {
|
||||
lines: {
|
||||
show: true
|
||||
}
|
||||
}
|
||||
}}, config);
|
||||
|
||||
// create empty linechart with parameter options
|
||||
var plot = $.plot(this.$element, [],options.line_options);
|
||||
|
||||
// dict containing the labels and values
|
||||
var linedata_series = {};
|
||||
|
||||
var initline = function(series) {
|
||||
for(var k in series) {
|
||||
linedata_series[series[k]] = {order:k,data:[]};
|
||||
}
|
||||
}
|
||||
|
||||
initline(options.line_series);
|
||||
|
||||
var addline = function(label, values) {
|
||||
var data;
|
||||
|
||||
if (linedata_series.hasOwnProperty(label))
|
||||
data = linedata_series[label].data;
|
||||
else
|
||||
data = linedata_series['default'].data;
|
||||
for(var v in values) {
|
||||
data.push(values[v]);
|
||||
}
|
||||
redraw();
|
||||
}
|
||||
|
||||
var setline = function(label, values) {
|
||||
if (linedata_series.hasOwnProperty(label))
|
||||
linedata_series[label].data = values;
|
||||
else
|
||||
linedata_series['default'].data = values;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var redraw = function() {
|
||||
var result = [];
|
||||
for(var k in linedata_series) {
|
||||
if (linedata_series.hasOwnProperty(k)) {
|
||||
var line_serie = linedata_series[k];
|
||||
|
||||
result.push({label:k,data:line_serie.data});
|
||||
}
|
||||
}
|
||||
plot.setData(result);
|
||||
plot.setupGrid();
|
||||
plot.draw();
|
||||
}
|
||||
|
||||
var reset = function() {
|
||||
initline(options.line_series);
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'set': function(e, message) {
|
||||
addline(message.series, message.value);
|
||||
},
|
||||
'add': function(e, message) {
|
||||
addline(message.series, message.value);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
})(jQuery, block);
|
||||
14
devjan_static/lib/log.js
Normal file
14
devjan_static/lib/log.js
Normal file
@@ -0,0 +1,14 @@
|
||||
(function($, block) {
|
||||
block.fn.log = function(config) {
|
||||
this.$element.addClass('block log').append('<ul>');
|
||||
|
||||
this.actions(function(e, message){
|
||||
$ul = $('ul:first-child', this);
|
||||
$ul.append('<li>');
|
||||
$ul.find("> li:last-child").text(message.text);
|
||||
$(this).scrollTop(1000000);
|
||||
});
|
||||
|
||||
return this.$element;
|
||||
};
|
||||
})(jQuery, block);
|
||||
64
devjan_static/lib/piecharts.js
Normal file
64
devjan_static/lib/piecharts.js
Normal file
@@ -0,0 +1,64 @@
|
||||
(function($, block) {
|
||||
|
||||
// a simple piechart example
|
||||
block.fn.piechart = function(config) {
|
||||
var options = $.extend({
|
||||
// see: http://www.flotcharts.org/flot/examples/series-pie/
|
||||
pie_options : {
|
||||
series: {
|
||||
pie: {
|
||||
show: true
|
||||
}
|
||||
}
|
||||
}}, config);
|
||||
|
||||
// create empty piechart with parameter options
|
||||
var plot = $.plot(this.$element, [],options.pie_options);
|
||||
|
||||
// dict containing the labels and values
|
||||
var piedata_dict = {};
|
||||
|
||||
var addpie = function(label, value) {
|
||||
if (piedata_dict.hasOwnProperty(label))
|
||||
piedata_dict[label] = (piedata_dict[label] + value);
|
||||
else
|
||||
piedata_dict[label] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var setpie = function(label, value) {
|
||||
piedata_dict[label] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var redraw = function() {
|
||||
var result = [];
|
||||
for(var k in piedata_dict) {
|
||||
if (piedata_dict.hasOwnProperty(k)) {
|
||||
result.push({label:k,data:piedata_dict[k]});
|
||||
}
|
||||
}
|
||||
plot.setData(result);
|
||||
plot.draw();
|
||||
}
|
||||
|
||||
var reset = function() {
|
||||
piedata_dict = {};
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'set': function(e, message) {
|
||||
setpie(message.value[0],message.value[1]);
|
||||
},
|
||||
'add': function(e, message) {
|
||||
addpie(message.value[0],message.value[1]);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
})(jQuery, block);
|
||||
71
devjan_static/lib/wordcloud.js
Normal file
71
devjan_static/lib/wordcloud.js
Normal file
@@ -0,0 +1,71 @@
|
||||
(function($, block) {
|
||||
// a simple wordcloud example
|
||||
block.fn.wordcloud = function(config) {
|
||||
var options = $.extend({
|
||||
// weight=0 means word is not in cloud
|
||||
weight_function : function(val,max) { return val; },
|
||||
}, config);
|
||||
|
||||
var $container = $(this.$element);
|
||||
// create empty wordcloud with parameter options
|
||||
|
||||
var wordcloud_el = $container.jQCloud([{
|
||||
text: "TEXT",
|
||||
weight: 1
|
||||
}]);
|
||||
|
||||
// dict containing the labels and values
|
||||
var worddata_dict = {};
|
||||
|
||||
var addword = function(label, value) {
|
||||
if (worddata_dict.hasOwnProperty(label)) {
|
||||
worddata_dict[label] += value;
|
||||
} else {
|
||||
worddata_dict[label] = value;
|
||||
}
|
||||
redraw();
|
||||
}
|
||||
|
||||
var setword = function(label, value) {
|
||||
worddata_dict[label] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var redraw = function() {
|
||||
var result = [];
|
||||
var max = 0;
|
||||
// incomplete, determine max
|
||||
for (var k in worddata_dict) {
|
||||
if (worddata_dict.hasOwnProperty(k)) {
|
||||
max = Math.max(max, worddata_dict[k]);
|
||||
}
|
||||
}
|
||||
for (var k in worddata_dict) {
|
||||
if (worddata_dict.hasOwnProperty(k)) {
|
||||
var w = options.weight_function(worddata_dict[k],max);
|
||||
if ( w > 0 )
|
||||
result.push({text: k, weight: w});
|
||||
}
|
||||
}
|
||||
$($container).empty().jQCloud(result);
|
||||
}
|
||||
|
||||
var reset = function() {
|
||||
worddata_dict = {};
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'set': function(e, message) {
|
||||
setword(message.value[0], message.value[1]);
|
||||
},
|
||||
'add': function(e, message) {
|
||||
addword(message.value[0], message.value[1]);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
})(jQuery, block);
|
||||
374
devjan_static/style/grid.css
Normal file
374
devjan_static/style/grid.css
Normal file
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
Variable Grid System.
|
||||
Learn more ~ http://www.spry-soft.com/grids/
|
||||
Based on 960 Grid System - http://960.gs/
|
||||
|
||||
Licensed under GPL and MIT.
|
||||
*/
|
||||
|
||||
/*
|
||||
Forces backgrounds to span full width,
|
||||
even if there is horizontal scrolling.
|
||||
Increase this if your layout is wider.
|
||||
|
||||
Note: IE6 works fine without this fix.
|
||||
*/
|
||||
|
||||
body {
|
||||
min-width: 960px;
|
||||
}
|
||||
|
||||
/* Containers
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
.container_12 {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 960px;
|
||||
}
|
||||
|
||||
/* Grid >> Global
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.grid_1,
|
||||
.grid_2,
|
||||
.grid_3,
|
||||
.grid_4,
|
||||
.grid_5,
|
||||
.grid_6,
|
||||
.grid_7,
|
||||
.grid_8,
|
||||
.grid_9,
|
||||
.grid_10,
|
||||
.grid_11,
|
||||
.grid_12 {
|
||||
display:inline;
|
||||
float: left;
|
||||
position: relative;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.push_1, .pull_1,
|
||||
.push_2, .pull_2,
|
||||
.push_3, .pull_3,
|
||||
.push_4, .pull_4,
|
||||
.push_5, .pull_5,
|
||||
.push_6, .pull_6,
|
||||
.push_7, .pull_7,
|
||||
.push_8, .pull_8,
|
||||
.push_9, .pull_9,
|
||||
.push_10, .pull_10,
|
||||
.push_11, .pull_11,
|
||||
.push_12, .pull_12 {
|
||||
position:relative;
|
||||
}
|
||||
|
||||
|
||||
/* Grid >> Children (Alpha ~ First, Omega ~ Last)
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
.alpha {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.omega {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Grid >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .grid_1 {
|
||||
width:60px;
|
||||
}
|
||||
|
||||
.container_12 .grid_2 {
|
||||
width:140px;
|
||||
}
|
||||
|
||||
.container_12 .grid_3 {
|
||||
width:220px;
|
||||
}
|
||||
|
||||
.container_12 .grid_4 {
|
||||
width:300px;
|
||||
}
|
||||
|
||||
.container_12 .grid_5 {
|
||||
width:380px;
|
||||
}
|
||||
|
||||
.container_12 .grid_6 {
|
||||
width:460px;
|
||||
}
|
||||
|
||||
.container_12 .grid_7 {
|
||||
width:540px;
|
||||
}
|
||||
|
||||
.container_12 .grid_8 {
|
||||
width:620px;
|
||||
}
|
||||
|
||||
.container_12 .grid_9 {
|
||||
width:700px;
|
||||
}
|
||||
|
||||
.container_12 .grid_10 {
|
||||
width:780px;
|
||||
}
|
||||
|
||||
.container_12 .grid_11 {
|
||||
width:860px;
|
||||
}
|
||||
|
||||
.container_12 .grid_12 {
|
||||
width:940px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Prefix Extra Space >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .prefix_1 {
|
||||
padding-left:80px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_2 {
|
||||
padding-left:160px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_3 {
|
||||
padding-left:240px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_4 {
|
||||
padding-left:320px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_5 {
|
||||
padding-left:400px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_6 {
|
||||
padding-left:480px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_7 {
|
||||
padding-left:560px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_8 {
|
||||
padding-left:640px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_9 {
|
||||
padding-left:720px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_10 {
|
||||
padding-left:800px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_11 {
|
||||
padding-left:880px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Suffix Extra Space >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .suffix_1 {
|
||||
padding-right:80px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_2 {
|
||||
padding-right:160px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_3 {
|
||||
padding-right:240px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_4 {
|
||||
padding-right:320px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_5 {
|
||||
padding-right:400px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_6 {
|
||||
padding-right:480px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_7 {
|
||||
padding-right:560px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_8 {
|
||||
padding-right:640px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_9 {
|
||||
padding-right:720px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_10 {
|
||||
padding-right:800px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_11 {
|
||||
padding-right:880px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Push Space >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .push_1 {
|
||||
left:80px;
|
||||
}
|
||||
|
||||
.container_12 .push_2 {
|
||||
left:160px;
|
||||
}
|
||||
|
||||
.container_12 .push_3 {
|
||||
left:240px;
|
||||
}
|
||||
|
||||
.container_12 .push_4 {
|
||||
left:320px;
|
||||
}
|
||||
|
||||
.container_12 .push_5 {
|
||||
left:400px;
|
||||
}
|
||||
|
||||
.container_12 .push_6 {
|
||||
left:480px;
|
||||
}
|
||||
|
||||
.container_12 .push_7 {
|
||||
left:560px;
|
||||
}
|
||||
|
||||
.container_12 .push_8 {
|
||||
left:640px;
|
||||
}
|
||||
|
||||
.container_12 .push_9 {
|
||||
left:720px;
|
||||
}
|
||||
|
||||
.container_12 .push_10 {
|
||||
left:800px;
|
||||
}
|
||||
|
||||
.container_12 .push_11 {
|
||||
left:880px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Pull Space >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .pull_1 {
|
||||
left:-80px;
|
||||
}
|
||||
|
||||
.container_12 .pull_2 {
|
||||
left:-160px;
|
||||
}
|
||||
|
||||
.container_12 .pull_3 {
|
||||
left:-240px;
|
||||
}
|
||||
|
||||
.container_12 .pull_4 {
|
||||
left:-320px;
|
||||
}
|
||||
|
||||
.container_12 .pull_5 {
|
||||
left:-400px;
|
||||
}
|
||||
|
||||
.container_12 .pull_6 {
|
||||
left:-480px;
|
||||
}
|
||||
|
||||
.container_12 .pull_7 {
|
||||
left:-560px;
|
||||
}
|
||||
|
||||
.container_12 .pull_8 {
|
||||
left:-640px;
|
||||
}
|
||||
|
||||
.container_12 .pull_9 {
|
||||
left:-720px;
|
||||
}
|
||||
|
||||
.container_12 .pull_10 {
|
||||
left:-800px;
|
||||
}
|
||||
|
||||
.container_12 .pull_11 {
|
||||
left:-880px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* `Clear Floated Elements
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
/* http://sonspring.com/journal/clearing-floats */
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* http://www.yuiblog.com/blog/2010/09/27/clearfix-reloaded-overflowhidden-demystified */
|
||||
|
||||
.clearfix:before,
|
||||
.clearfix:after {
|
||||
content: '\0020';
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.clearfix:after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
/*
|
||||
The following zoom:1 rule is specifically for IE6 + IE7.
|
||||
Move to separate stylesheet if invalid CSS is a problem.
|
||||
*/
|
||||
|
||||
.clearfix {
|
||||
zoom: 1;
|
||||
}
|
||||
53
devjan_static/style/layout.css
Normal file
53
devjan_static/style/layout.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
** Base layout:
|
||||
** Grid layout + vertical sizing classes sized to match
|
||||
*/
|
||||
|
||||
/* Grid layout based on (http://960.gs/) */
|
||||
@import url(grid.css);
|
||||
|
||||
/* Vertical classes */
|
||||
|
||||
.grid_1, .vert_1,
|
||||
.grid_2, .vert_2,
|
||||
.grid_3, .vert_3,
|
||||
.grid_4, .vert_4,
|
||||
.grid_5, .vert_5,
|
||||
.grid_6, .vert_6,
|
||||
.grid_7, .vert_7,
|
||||
.grid_8, .vert_8,
|
||||
.grid_9, .vert_9,
|
||||
.grid_10, .vert_10,
|
||||
.grid_11, .vert_11,
|
||||
.grid_12, .vert_12 {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
.container_12 .vert_1 { height:60px; }
|
||||
.container_12 .vert_2 { height:140px; }
|
||||
.container_12 .vert_3 { height:220px; }
|
||||
.container_12 .vert_4 { height:300px; }
|
||||
.container_12 .vert_5 { height:380px; }
|
||||
.container_12 .vert_6 { height:460px; }
|
||||
.container_12 .vert_7 { height:540px; }
|
||||
.container_12 .vert_8 { height:620px; }
|
||||
.container_12 .vert_9 { height:700px; }
|
||||
.container_12 .vert_10 { height:780px; }
|
||||
.container_12 .vert_11 { height:860px; }
|
||||
.container_12 .vert_12 { height:940px; }
|
||||
|
||||
|
||||
/* Layout details */
|
||||
|
||||
p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Log block */
|
||||
|
||||
.block.log {
|
||||
overflow: auto;
|
||||
}
|
||||
30
devjan_static/style/theme.css
Normal file
30
devjan_static/style/theme.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Basic style & theme*/
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #eee;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 2px;
|
||||
padding: 0 0.2em;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0.5em 1.5em;
|
||||
background-color: #eee;
|
||||
border-top: 1px solid #ddd;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* Devevlopment helpers */
|
||||
|
||||
.debug_red { background-color: rgba(255,0,0,0.5); }
|
||||
.debug_green { background-color: rgba(0,255,0,0.5); }
|
||||
.debug_blue { background-color: rgba(0,0,255,0.5); }
|
||||
|
||||
|
||||
326
eca/__init__.py
Normal file
326
eca/__init__.py
Normal file
@@ -0,0 +1,326 @@
|
||||
import queue
|
||||
import collections
|
||||
import threading
|
||||
import logging
|
||||
import sys
|
||||
import json
|
||||
|
||||
from contextlib import contextmanager
|
||||
from . import util
|
||||
from . import pubsub
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# all exported names
|
||||
__all__ = [
|
||||
'event',
|
||||
'condition',
|
||||
'rules',
|
||||
'Rules',
|
||||
'Context',
|
||||
'Event',
|
||||
'fire',
|
||||
'fire_global',
|
||||
'emit',
|
||||
'get_context',
|
||||
'spawn_context',
|
||||
'context_activate',
|
||||
'context_switch',
|
||||
'auxiliary',
|
||||
'register_auxiliary',
|
||||
'shutdown'
|
||||
]
|
||||
|
||||
# The global event channel
|
||||
global_channel = pubsub.PubSubChannel()
|
||||
|
||||
# The thread local storage used to create a 'current context' with regards
|
||||
# to the executing thread.
|
||||
# (See https://docs.python.org/3/library/threading.html#thread-local-data)
|
||||
thread_local = threading.local()
|
||||
|
||||
|
||||
class Rules:
|
||||
def __init__(self):
|
||||
self.rules = set()
|
||||
|
||||
def prepare_action(self, fn):
|
||||
"""
|
||||
Prepares a function to be usable as an action.
|
||||
|
||||
This function assigns an empty list of the 'conditions' attribute if it is
|
||||
not yet available. This function also registers the action with the action
|
||||
library.
|
||||
"""
|
||||
if not hasattr(fn, 'conditions'):
|
||||
logger.info("Defined action '{}'".format(fn.__name__))
|
||||
fn.conditions = getattr(fn, 'conditions', [])
|
||||
fn.events = getattr(fn, 'events', set())
|
||||
self.rules.add(fn)
|
||||
|
||||
|
||||
def condition(self, c):
|
||||
"""
|
||||
Adds a condition callable to the action.
|
||||
|
||||
The condition must be callable. The condition will receive a context and
|
||||
an event, and must return True or False.
|
||||
|
||||
This function returns a decorator so we can pass an argument to the
|
||||
decorator itself. This is why we define a new function and return it
|
||||
without calling it.
|
||||
|
||||
(See http://docs.python.org/3/glossary.html#term-decorator)
|
||||
|
||||
"""
|
||||
def condition_decorator(fn):
|
||||
self.prepare_action(fn)
|
||||
logger.debug("With condition: {}".format(util.describe_function(c)))
|
||||
fn.conditions.append(c)
|
||||
return fn
|
||||
return condition_decorator
|
||||
|
||||
|
||||
def event(self, eventname):
|
||||
"""
|
||||
Attaches the action to an event.
|
||||
|
||||
This is effectively the same as adding the 'event.name == eventname'
|
||||
condition. Adding multiple event names will prevent the rule from
|
||||
triggering.
|
||||
|
||||
As condition, this function generates a decorator.
|
||||
"""
|
||||
def event_decorator(fn):
|
||||
self.prepare_action(fn)
|
||||
logger.debug("Attached to event: {}".format(eventname))
|
||||
fn.events.add(eventname)
|
||||
return fn
|
||||
return event_decorator
|
||||
|
||||
|
||||
# The 'global' rules set
|
||||
rules = Rules()
|
||||
|
||||
event = rules.event
|
||||
condition = rules.condition
|
||||
|
||||
class Event:
|
||||
"""Abstract event with a name and attributes."""
|
||||
def __init__(self, name, data=None):
|
||||
"""Constructs an event.
|
||||
|
||||
Attributes are optional.
|
||||
|
||||
"""
|
||||
self.name = name
|
||||
self.data = data
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return self.data.get(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
data_strings = []
|
||||
if isinstance(self.data, collections.abc.Mapping):
|
||||
for k, v in self.data.items():
|
||||
data_strings.append("{}={}".format(k, v))
|
||||
else:
|
||||
data_strings.append(str(self.data))
|
||||
return "'{}' with {{{}}}".format(self.name, ', '.join(data_strings))
|
||||
|
||||
|
||||
class Context:
|
||||
"""
|
||||
ECA Execution context to track scope and events.
|
||||
|
||||
Each context maintains both a variables namespace and an event queue. The
|
||||
context itself provides a run method to allow threaded execution through
|
||||
starting a new thread targetted at the run method.
|
||||
|
||||
Every context also contains a dictionary of auxiliaries which contains
|
||||
objects to support the context and its rule execution.
|
||||
"""
|
||||
def __init__(self, init_data=None, name='<unnamed context>', rules=rules):
|
||||
self.event_queue = queue.Queue()
|
||||
self.scope = util.NamespaceDict()
|
||||
self.channel = pubsub.PubSubChannel()
|
||||
self.auxiliaries = {}
|
||||
self.name = name
|
||||
self.done = False
|
||||
self.daemon = True
|
||||
self.rules = rules
|
||||
|
||||
# subscribe to own pubsub channel to receive events
|
||||
self.channel.subscribe(self._pubsub_receiver, 'event')
|
||||
self.receive_event(Event('init', init_data))
|
||||
|
||||
# subscribe to global pubsub channel to receive global eca events
|
||||
global_channel.subscribe(self._pubsub_receiver, 'event')
|
||||
|
||||
def _trace(self, message):
|
||||
"""Prints tracing statements if trace is enabled."""
|
||||
logging.getLogger('trace').info(message)
|
||||
|
||||
def _pubsub_receiver(self, name, data):
|
||||
"""Pubsub channel connector."""
|
||||
self.receive_event(data)
|
||||
|
||||
def receive_event(self, event):
|
||||
"""Receives an Event to handle."""
|
||||
self._trace("Received event: {}".format(event))
|
||||
self.event_queue.put(event)
|
||||
|
||||
def auxiliary(self, name):
|
||||
return self.auxiliaries[name]
|
||||
|
||||
def run(self):
|
||||
"""Main event loop."""
|
||||
# switch context to this one and start working
|
||||
with context_switch(self):
|
||||
while not self.done:
|
||||
self._handle_event()
|
||||
|
||||
def start(self, daemon=True):
|
||||
thread = threading.Thread(target=self.run)
|
||||
self.daemon = daemon
|
||||
thread.daemon = self.daemon
|
||||
thread.start()
|
||||
|
||||
def stop(self):
|
||||
global_channel.unsubscribe(self._pubsub_receiver, 'event')
|
||||
if not self.daemon:
|
||||
self.done = True
|
||||
else:
|
||||
logger.warning("Can't shutdown daemon context. The context is used in a server.")
|
||||
|
||||
def _handle_event(self):
|
||||
"""Handles a single event, or times out after receiving nothing."""
|
||||
try:
|
||||
# wait until we have an upcoming event
|
||||
# (but don't wait too long -- self.done could have been set to
|
||||
# true while we were waiting for an event)
|
||||
event = self.event_queue.get(timeout=1.0)
|
||||
|
||||
self._trace("Working on event: {}".format(event))
|
||||
|
||||
# Determine candidate rules and execute matches:
|
||||
# 1) Only rules that match the event name as one of the events
|
||||
candidates = [r for r in self.rules.rules if event.name in r.events]
|
||||
|
||||
# 2) Only rules for which all conditions hold
|
||||
for r in candidates:
|
||||
if not [c(self.scope, event) for c in r.conditions].count(False):
|
||||
self._trace("Rule: {}".format(util.describe_function(r)))
|
||||
result = r(self.scope, event)
|
||||
|
||||
except queue.Empty:
|
||||
# Timeout on waiting
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def context_switch(context):
|
||||
"""
|
||||
Context manager to allow ad-hoc context switches. (The Python 'context' is
|
||||
different from the eca Context.)
|
||||
|
||||
This function can be written without any regard for locking as the
|
||||
thread_local object will take care of that. Since everything here is done
|
||||
in the same thread, this effectively allows nesting of context switches.
|
||||
"""
|
||||
# activate new context and store old
|
||||
old_context = context_activate(context)
|
||||
|
||||
yield
|
||||
|
||||
# restore old context
|
||||
context_activate(old_context)
|
||||
|
||||
|
||||
def context_activate(context):
|
||||
"""
|
||||
Activate an eca Context. If None is passed, this function should
|
||||
disable the context.
|
||||
"""
|
||||
# stash old context
|
||||
old_context = getattr(thread_local, 'context', None)
|
||||
|
||||
# switch to new context
|
||||
thread_local.context = context
|
||||
|
||||
return old_context
|
||||
|
||||
|
||||
def get_context():
|
||||
"""Returns the current context."""
|
||||
return getattr(thread_local, 'context', None)
|
||||
|
||||
|
||||
def auxiliary(name):
|
||||
"""
|
||||
Returns an auxiliary for this context.
|
||||
"""
|
||||
context = get_context()
|
||||
if context is None:
|
||||
raise NotImplementedError("Can not get an auxiliary without a current context.")
|
||||
return context.auxiliaries[name]
|
||||
|
||||
|
||||
def register_auxiliary(name, aux):
|
||||
"""
|
||||
Registers an auxiliary object for this context.
|
||||
"""
|
||||
context = get_context()
|
||||
if context is None:
|
||||
raise NotImplementedError("Can not get an auxiliary without a current context.")
|
||||
context.auxiliaries[name] = aux
|
||||
|
||||
|
||||
def shutdown():
|
||||
context = get_context()
|
||||
if context is None:
|
||||
raise NotImplementedError("Can not invoke shutdown without a current context.")
|
||||
context.stop()
|
||||
|
||||
def fire(eventname, data=None, delay=None):
|
||||
"""
|
||||
Fires an event.
|
||||
|
||||
This is the fire-and-forget method to create new events.
|
||||
"""
|
||||
e = Event(eventname, data)
|
||||
context = get_context()
|
||||
if context is None:
|
||||
raise NotImplementedError("Can't invoke fire without a current context.")
|
||||
context.channel.publish('event', e, delay)
|
||||
|
||||
|
||||
def fire_global(eventname, data=None, delay=None):
|
||||
"""
|
||||
Fires a global event.
|
||||
"""
|
||||
e = Event(eventname, data)
|
||||
global_channel.publish('event', e, delay)
|
||||
|
||||
|
||||
def emit(name, data, id=None):
|
||||
"""
|
||||
Emits an event to whomever is listening (mostly HTTP clients).
|
||||
"""
|
||||
e = Event(name, {
|
||||
'json': json.dumps(data),
|
||||
'id': id
|
||||
})
|
||||
|
||||
context = get_context()
|
||||
if context is None:
|
||||
raise NotImplementedError("Can't invoke emit without a current context.")
|
||||
context.channel.publish('emit', e)
|
||||
|
||||
|
||||
def spawn_context(init_data=None, name='<unnamed context>', rules=rules, daemon=False):
|
||||
"""
|
||||
Spawns a new context and starts it.
|
||||
"""
|
||||
context = Context(init_data, name, rules)
|
||||
context.start(daemon)
|
||||
BIN
eca/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
eca/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
eca/__pycache__/http.cpython-310.pyc
Normal file
BIN
eca/__pycache__/http.cpython-310.pyc
Normal file
Binary file not shown.
BIN
eca/__pycache__/httpd.cpython-310.pyc
Normal file
BIN
eca/__pycache__/httpd.cpython-310.pyc
Normal file
Binary file not shown.
BIN
eca/__pycache__/pubsub.cpython-310.pyc
Normal file
BIN
eca/__pycache__/pubsub.cpython-310.pyc
Normal file
Binary file not shown.
BIN
eca/__pycache__/sessions.cpython-310.pyc
Normal file
BIN
eca/__pycache__/sessions.cpython-310.pyc
Normal file
Binary file not shown.
BIN
eca/__pycache__/sse.cpython-310.pyc
Normal file
BIN
eca/__pycache__/sse.cpython-310.pyc
Normal file
Binary file not shown.
BIN
eca/__pycache__/util.cpython-310.pyc
Normal file
BIN
eca/__pycache__/util.cpython-310.pyc
Normal file
Binary file not shown.
384
eca/arff.py
Normal file
384
eca/arff.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
ARFF format loading and saving module.
|
||||
|
||||
This module implements the book version [1] of the ARFF format. This means there
|
||||
is no support for instance weights.
|
||||
|
||||
Known limitations:
|
||||
- This implementation does not parse dates
|
||||
|
||||
[1]: http://weka.wikispaces.com/ARFF+%28book+version%29
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections import namedtuple
|
||||
|
||||
Field = namedtuple('Field',['name','type'])
|
||||
|
||||
__all__ = ['load', 'save', 'Field', 'Numeric', 'Text', 'Nominal']
|
||||
|
||||
|
||||
#
|
||||
# Line type functions
|
||||
#
|
||||
|
||||
def is_empty(line):
|
||||
return not line.strip()
|
||||
|
||||
def is_comment(line):
|
||||
return line.startswith('%')
|
||||
|
||||
def format_comment(line):
|
||||
return '% '+line
|
||||
|
||||
def is_relation(line):
|
||||
return line.lower().startswith('@relation')
|
||||
|
||||
def format_relation(name):
|
||||
return '@relation ' + format_identifier(name) + '\n'
|
||||
|
||||
def is_attribute(line):
|
||||
return line.lower().startswith('@attribute')
|
||||
|
||||
def format_attribute(field):
|
||||
return '@attribute ' + format_identifier(field.name) + ' ' + str(field.type) + '\n'
|
||||
|
||||
def format_attributes(fields):
|
||||
result = []
|
||||
for field in fields:
|
||||
result.append(format_attribute(field))
|
||||
return ''.join(result)
|
||||
|
||||
def is_data(line):
|
||||
return line.lower().startswith('@data')
|
||||
|
||||
def format_data():
|
||||
return '@data\n'
|
||||
|
||||
def format_row(row, fields, sparse=False):
|
||||
"""Formats a data row based on the given fields."""
|
||||
if sparse:
|
||||
result = []
|
||||
for i in range(len(fields)):
|
||||
field = fields[i]
|
||||
val = row.get(field.name)
|
||||
if val != field.type.default():
|
||||
result.append(format_numeric(i) + ' ' + field.type.format(val))
|
||||
return '{' + ','.join(result) + '}\n'
|
||||
else:
|
||||
result = []
|
||||
for field in fields:
|
||||
result.append(field.type.format(row.get(field.name)))
|
||||
return ','.join(result)+'\n'
|
||||
|
||||
|
||||
def safe_next(it):
|
||||
"""Returns the next character from the iterator or ''."""
|
||||
try:
|
||||
return next(it)
|
||||
except StopIteration:
|
||||
return ''
|
||||
|
||||
|
||||
def whitespace(rest):
|
||||
"""Parses whitespace at the beginning of the input."""
|
||||
return rest.lstrip()
|
||||
|
||||
|
||||
number_pattern = re.compile(r'[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?')
|
||||
|
||||
def numeric(rest):
|
||||
"""Parses a number at the beginning of the input."""
|
||||
m = number_pattern.match(rest)
|
||||
if m:
|
||||
rest = rest[len(m.group(0)):]
|
||||
try:
|
||||
number = int(m.group(0))
|
||||
except ValueError:
|
||||
number = float(m.group(0))
|
||||
return number, rest
|
||||
else:
|
||||
raise ValueError('Number not parsable')
|
||||
|
||||
def format_numeric(number):
|
||||
"""Outputs a number."""
|
||||
return str(number)
|
||||
|
||||
def expect(rest, string):
|
||||
"""Expects to see the string at the start of the input."""
|
||||
result = rest.startswith(string)
|
||||
if result:
|
||||
return result, rest[len(string):]
|
||||
else:
|
||||
return False, rest
|
||||
|
||||
|
||||
identifier_escapes = {
|
||||
'\\': '\\',
|
||||
'n' : '\n',
|
||||
't' : '\t',
|
||||
'r' : '\r',
|
||||
'%' : '%',
|
||||
"'" : "'"
|
||||
}
|
||||
def identifier(rest):
|
||||
"""Parses an optionally quoted identifier at the start of the input."""
|
||||
name = ''
|
||||
|
||||
it = iter(rest)
|
||||
c = safe_next(it)
|
||||
|
||||
# non-quoted
|
||||
if c != "'":
|
||||
while c and c not in [' ', '\t', ',']:
|
||||
name += c
|
||||
c = safe_next(it)
|
||||
return name, c + ''.join(it)
|
||||
|
||||
# quoted
|
||||
|
||||
# discard the opening quote by fetching next character
|
||||
c = safe_next(it)
|
||||
while c:
|
||||
if c == '\\':
|
||||
ec = safe_next(it)
|
||||
if not ec:
|
||||
raise ValueError('Input end during escape.')
|
||||
try:
|
||||
name += identifier_escapes[ec]
|
||||
except KeyError:
|
||||
name += '\\' + ec
|
||||
elif c == "'":
|
||||
break
|
||||
else:
|
||||
name += c
|
||||
c = safe_next(it)
|
||||
return name, ''.join(it)
|
||||
|
||||
def format_identifier(name):
|
||||
"""Formats an identifier."""
|
||||
reverse_escapes = { c:ec for (ec,c) in identifier_escapes.items()}
|
||||
if any(x in name for x in [' ',','] + list(reverse_escapes.keys())):
|
||||
escaped = ''
|
||||
for c in name:
|
||||
if c in reverse_escapes:
|
||||
escaped += '\\' + reverse_escapes[c]
|
||||
else:
|
||||
escaped += c
|
||||
return "'"+escaped+"'"
|
||||
|
||||
return name
|
||||
|
||||
class Numeric:
|
||||
"""Numeric field type."""
|
||||
def parse(self, rest):
|
||||
if rest.startswith('?'):
|
||||
return None, rest[1:]
|
||||
|
||||
return numeric(rest)
|
||||
|
||||
def format(self, number):
|
||||
if number is None:
|
||||
return '?'
|
||||
else:
|
||||
return format_numeric(number)
|
||||
|
||||
def default(self):
|
||||
return 0
|
||||
|
||||
def __repr__(self):
|
||||
return 'Numeric'
|
||||
|
||||
def __str__(self):
|
||||
return 'numeric'
|
||||
|
||||
|
||||
class Text:
|
||||
"""Text field type."""
|
||||
def parse(self, rest):
|
||||
if rest.startswith('?'):
|
||||
return None, rest[1:]
|
||||
|
||||
return identifier(rest)
|
||||
|
||||
def format(self, name):
|
||||
if name is None:
|
||||
return '?'
|
||||
else:
|
||||
return format_identifier(name)
|
||||
|
||||
def default(self):
|
||||
return ''
|
||||
|
||||
def __repr__(self):
|
||||
return 'Text'
|
||||
|
||||
def __str__(self):
|
||||
return 'string'
|
||||
|
||||
|
||||
class Nominal:
|
||||
"""Nominal field type."""
|
||||
def __init__(self, names):
|
||||
self.values = names
|
||||
|
||||
def parse(self, rest):
|
||||
if rest.startswith('?'):
|
||||
return None, rest[1:]
|
||||
|
||||
name, rest = identifier(rest)
|
||||
if name in self.values:
|
||||
return name, rest
|
||||
else:
|
||||
raise ValueError('Unknown nominal constant "{}" for {}.'.format(name, self.values))
|
||||
|
||||
def format(self, name):
|
||||
if name is None:
|
||||
return '?'
|
||||
else:
|
||||
if name not in self.values:
|
||||
raise ValueError('Unknown nominal constant "{}" for {}.'.format(name, self.values))
|
||||
return format_identifier(name)
|
||||
|
||||
def default(self):
|
||||
return self.values[0]
|
||||
|
||||
def __repr__(self):
|
||||
return 'Nominal in {}'.format(self.values)
|
||||
|
||||
def __str__(self):
|
||||
return '{' + ', '.join(format_identifier(name) for name in self.values) + '}'
|
||||
|
||||
|
||||
def attr_type(rest):
|
||||
"""Parses a field type. Uses the whole rest."""
|
||||
if rest.lower() in ['numeric', 'integer', 'real']:
|
||||
return Numeric()
|
||||
elif rest.lower() in ['string']:
|
||||
return Text()
|
||||
elif rest.lower().startswith('date'):
|
||||
raise NotImplementedError('date parsing is not implemented.')
|
||||
elif rest.startswith('{') and rest.endswith('}'):
|
||||
names = []
|
||||
rest = rest[1:-1]
|
||||
while rest:
|
||||
rest = whitespace(rest)
|
||||
name, rest = identifier(rest)
|
||||
names.append(name)
|
||||
rest = whitespace(rest)
|
||||
seen, rest = expect(rest, ',')
|
||||
if not seen:
|
||||
break
|
||||
return Nominal(names)
|
||||
else:
|
||||
raise ValueError('Unknown attribute type "{}"'.format(rest))
|
||||
|
||||
|
||||
def parse_attribute(line):
|
||||
"""Parses an attribute line."""
|
||||
# @attribute WS name WS type
|
||||
rest = line[len('@attribute'):].strip()
|
||||
rest = whitespace(rest)
|
||||
name, rest = identifier(rest)
|
||||
rest = whitespace(rest)
|
||||
type = attr_type(rest)
|
||||
return name, type
|
||||
|
||||
|
||||
def parse_row(line, fields):
|
||||
"""Parses a row. Row can be normal or sparse."""
|
||||
line = line.strip()
|
||||
values = {}
|
||||
|
||||
if not line.startswith('{'):
|
||||
rest = line
|
||||
first = True
|
||||
for field in fields:
|
||||
if not first:
|
||||
rest = whitespace(rest)
|
||||
seen, rest = expect(rest, ',')
|
||||
first = False
|
||||
rest = whitespace(rest)
|
||||
value, rest = field.type.parse(rest)
|
||||
values[field.name] = value
|
||||
return values
|
||||
else:
|
||||
todo = set(range(len(fields)))
|
||||
rest = line[1:-1].strip()
|
||||
first = True
|
||||
while rest:
|
||||
if not first:
|
||||
rest = whitespace(rest)
|
||||
seen, rest = expect(rest, ',')
|
||||
if not seen:
|
||||
break
|
||||
first = False
|
||||
rest = whitespace(rest)
|
||||
index, rest = numeric(rest)
|
||||
field = fields[index]
|
||||
rest = whitespace(rest)
|
||||
value, rest = field.type.parse(rest)
|
||||
todo.remove(index)
|
||||
values[field.name] = value
|
||||
for field in (fields[i] for i in todo):
|
||||
values[field.name] = field.type.default()
|
||||
return values
|
||||
|
||||
|
||||
def load(fileish):
|
||||
"""
|
||||
Loads a data set from an arff formatted file-like object.
|
||||
|
||||
This generator function will parse the arff format's header to determine
|
||||
data shape. Each generated item is a single expanded row.
|
||||
|
||||
fileish -- a file-like object
|
||||
"""
|
||||
# parse header first
|
||||
lines = iter(fileish)
|
||||
fields = []
|
||||
|
||||
for line in lines:
|
||||
if is_empty(line) or is_comment(line):
|
||||
continue
|
||||
|
||||
if is_relation(line):
|
||||
# No care is given for the relation name.
|
||||
continue
|
||||
|
||||
if is_attribute(line):
|
||||
name, type = parse_attribute(line)
|
||||
fields.append(Field(name, type))
|
||||
continue
|
||||
|
||||
if is_data(line):
|
||||
# We are done with the header, next up is 1 row per line
|
||||
break
|
||||
|
||||
# parse data lines
|
||||
for line in lines:
|
||||
if is_empty(line) or is_comment(line):
|
||||
continue
|
||||
row = parse_row(line, fields)
|
||||
yield row
|
||||
|
||||
def save(fileish, fields, rows, name='unnamed relation', sparse=False):
|
||||
"""
|
||||
Saves an arff formatted data set to a file-like object.
|
||||
|
||||
The rows parameter can be any iterable. The fields parameter must be a list
|
||||
of `Field` instances.
|
||||
|
||||
fileish -- a file-like object to write to
|
||||
fields -- a list of `Field` instances
|
||||
rows -- an iterable containing one dictionary per data row
|
||||
name -- the relation name, defaults to 'unnamed relation'
|
||||
sparse -- whether the output should be in sparse format, defaults to False
|
||||
"""
|
||||
fileish.write(format_relation(name))
|
||||
fileish.write('\n')
|
||||
fileish.write(format_attributes(fields))
|
||||
fileish.write('\n')
|
||||
fileish.write(format_data())
|
||||
for row in rows:
|
||||
fileish.write(format_row(row, fields, sparse))
|
||||
120
eca/generators.py
Normal file
120
eca/generators.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
import json
|
||||
from . import fire, get_context, context_switch, register_auxiliary, auxiliary
|
||||
from . import arff
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EventGenerator:
|
||||
"""
|
||||
An event generator uses a generation function to generate events from
|
||||
any external source.
|
||||
"""
|
||||
def __init__(self, context, generator, event_name='tweet', **kwargs):
|
||||
self.context = context
|
||||
self.event_name = event_name
|
||||
self.generator = generator
|
||||
self.generator_args = kwargs
|
||||
self.stop_flag = threading.Event()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Starts a thread to handle run this generator.
|
||||
"""
|
||||
thread = threading.Thread(target=self.run)
|
||||
thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Requests shutdown of generator.
|
||||
"""
|
||||
self.stop_flag.set()
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Invoke the generator to get a sequence of events.
|
||||
|
||||
This method passes an event to the generator which will be set to True
|
||||
if the generator should terminate. Immediate termination is not required.
|
||||
"""
|
||||
logger.debug("Running event generator")
|
||||
with context_switch(self.context):
|
||||
for event in self.generator(self.stop_flag, **self.generator_args):
|
||||
fire(self.event_name, event)
|
||||
|
||||
|
||||
def offline_tweets(stop, data_file, time_factor=1000, arff_file=None):
|
||||
"""
|
||||
Offline tweet replay.
|
||||
|
||||
Takes a datafile formatted with 1 tweet per line, and generates a sequence of
|
||||
scaled realtime items.
|
||||
"""
|
||||
|
||||
# timing functions return false if we need to abort
|
||||
def delayer(duration):
|
||||
logger.debug("Delay for next tweet {}s ({}s real)".format(delay, delay/time_factor))
|
||||
return not stop.wait(delay / time_factor)
|
||||
|
||||
def immediate(duration):
|
||||
return not stop.is_set()
|
||||
|
||||
# select timing function based on time_factor
|
||||
delayed = immediate if time_factor is None else delayer
|
||||
|
||||
arff_data = None
|
||||
if arff_file:
|
||||
arff_file = open(arff_file, 'r', encoding='utf-8')
|
||||
arff_data = arff.load(arff_file)
|
||||
|
||||
with open(data_file, encoding='utf-8') as data:
|
||||
last_time = None
|
||||
lines = 0
|
||||
for line in data:
|
||||
lines += 1
|
||||
|
||||
try:
|
||||
tweet = json.loads(line)
|
||||
if arff_file:
|
||||
try:
|
||||
extra_data = next(arff_data)
|
||||
except StopIteration:
|
||||
extra_data = None
|
||||
except ValueError as e:
|
||||
logger.error("Could not read arff line for tweet (reason: {})".format(e))
|
||||
extra_data = None
|
||||
tweet['extra'] = extra_data
|
||||
except ValueError as e:
|
||||
logger.error("Could not read tweet on {}:{} (reason: {})".format(data_file,lines, e))
|
||||
continue
|
||||
|
||||
# time scale the tweet
|
||||
tweet_time = datetime.strptime(tweet['created_at'], '%a %b %d %H:%M:%S %z %Y')
|
||||
|
||||
if not last_time:
|
||||
last_time = tweet_time
|
||||
|
||||
wait = tweet_time - last_time
|
||||
delay = wait.total_seconds()
|
||||
|
||||
# delay and yield or break depending on success
|
||||
if delayed(delay):
|
||||
yield tweet
|
||||
last_time = tweet_time
|
||||
else:
|
||||
break
|
||||
if arff_file:
|
||||
arff_file.close()
|
||||
|
||||
|
||||
def start_offline_tweets(data_file, event_name='tweet', aux_name='tweeter', **kwargs):
|
||||
context = get_context()
|
||||
if context is None:
|
||||
raise NotImplementedError("Can not start offline tweet replay outside of a context.")
|
||||
register_auxiliary(aux_name, EventGenerator(context, generator=offline_tweets, data_file=data_file, event_name=event_name, **kwargs))
|
||||
auxiliary(aux_name).start()
|
||||
135
eca/http.py
Normal file
135
eca/http.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import http.cookies
|
||||
import logging
|
||||
import json
|
||||
import collections
|
||||
from . import httpd
|
||||
from . import sse
|
||||
from . import fire, get_context
|
||||
from . import sessions
|
||||
|
||||
|
||||
# Logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# bring some external handlers into this module's scope
|
||||
# (now you can just import eca.http and have access to all standard handlers)
|
||||
StaticContent = httpd.StaticContent
|
||||
SessionManager = sessions.SessionManager
|
||||
|
||||
|
||||
class Cookies(httpd.Filter):
|
||||
"""Filter to read cookies from request."""
|
||||
def handle(self):
|
||||
# process available cookies
|
||||
cookies = http.cookies.SimpleCookie()
|
||||
if 'cookie' in self.request.headers:
|
||||
cookies.load(self.request.headers['cookie'])
|
||||
|
||||
# set cookies on request
|
||||
self.request.cookies = cookies
|
||||
|
||||
|
||||
|
||||
class HelloWorld(httpd.Handler):
|
||||
"""The mandatory Hello World example."""
|
||||
def handle_GET(self):
|
||||
self.request.send_response(200)
|
||||
self.request.send_header('content-type','text/html; charset=utf-8')
|
||||
self.request.end_headers()
|
||||
|
||||
output = "<!DOCTYPE html><html><body><h1>Hello world!</h1><p><i>eca-session:</i> {}</p></body></html>"
|
||||
|
||||
try:
|
||||
if not hasattr(self.request, 'cookies'): raise KeyError()
|
||||
cookie = self.request.cookies['eca-session'].value
|
||||
except KeyError:
|
||||
cookie = '<i>no cookie</i>';
|
||||
|
||||
self.request.wfile.write(output.format(cookie).encode('utf-8'))
|
||||
|
||||
|
||||
def Redirect(realpath):
|
||||
"""
|
||||
Factory for redirection handlers.
|
||||
"""
|
||||
class RedirectHandler(httpd.Handler):
|
||||
def handle_GET(self):
|
||||
location = None
|
||||
|
||||
# check for absolute paths
|
||||
if realpath.startswith("http://") or realpath.startswith('https://'):
|
||||
location = realpath
|
||||
else:
|
||||
host = self.request.server.server_address[0]
|
||||
if self.request.server.server_address[1] != 80:
|
||||
host += ":{}".format(self.request.server.server_address[1])
|
||||
|
||||
if 'host' in self.request.headers:
|
||||
host = self.request.headers['host']
|
||||
|
||||
location = "http://{}{}".format(host, realpath)
|
||||
|
||||
self.request.send_response(302)
|
||||
self.request.send_header('content-type','text/html; charset=utf-8')
|
||||
self.request.send_header('location',location)
|
||||
self.request.end_headers()
|
||||
output = "<!DOCTYPE html><html><body><p>Redirect to <a href='{0}'>{0}</a></p></body></html>"
|
||||
self.request.wfile.write(output.format(location).encode('utf-8'))
|
||||
|
||||
return RedirectHandler
|
||||
|
||||
|
||||
def GenerateEvent(name):
|
||||
"""
|
||||
This function returns a handler class that creates the named event based
|
||||
on the posted JSON data.
|
||||
"""
|
||||
class EventGenerationHandler(httpd.Handler):
|
||||
def handle_POST(self):
|
||||
# handle weirdness
|
||||
if 'content-length' not in self.request.headers:
|
||||
self.request.send_error(411)
|
||||
return
|
||||
|
||||
# read content-length header
|
||||
length = int(self.request.headers['content-length'])
|
||||
|
||||
# grab data
|
||||
data = self.request.rfile.read(length)
|
||||
try:
|
||||
structured = json.loads(data.decode('utf-8'))
|
||||
except ValueError as e:
|
||||
self.request.send_error(400, "Bad request: "+str(e))
|
||||
return
|
||||
|
||||
if not isinstance(structured, collections.abc.Mapping):
|
||||
self.request.send_error(400, "Bad request: expect a JSON object")
|
||||
return
|
||||
|
||||
try:
|
||||
fire(name, structured)
|
||||
except NotImplementedError:
|
||||
logger.warn("Event generated by HTTP request without active session. Do you have a SessionManager configured?")
|
||||
self.request.send_error(500, "No current context available.")
|
||||
return
|
||||
|
||||
self.request.send_response(202)
|
||||
self.request.send_header('content-type', 'text/plain; charset=utf-8')
|
||||
self.request.send_header('content-length', 0)
|
||||
self.request.end_headers()
|
||||
|
||||
return EventGenerationHandler
|
||||
|
||||
|
||||
class EventStream(sse.ServerSideEvents):
|
||||
def go_subscribe(self):
|
||||
def receiver(name, event):
|
||||
self.send_event(event.data.get('json'), event.name, event.data.get('id'))
|
||||
|
||||
self.receiver = receiver
|
||||
context = get_context()
|
||||
context.channel.subscribe(self.receiver, 'emit')
|
||||
|
||||
def go_unsubscribe(self):
|
||||
context = get_context()
|
||||
context.channel.unsubscribe(self.receiver, 'emit')
|
||||
326
eca/httpd.py
Normal file
326
eca/httpd.py
Normal file
@@ -0,0 +1,326 @@
|
||||
import http.server
|
||||
import http.cookies
|
||||
import socketserver
|
||||
import logging
|
||||
|
||||
import os.path
|
||||
import posixpath
|
||||
import urllib
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
# Logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hard-coded default error message
|
||||
DEFAULT_ERROR_MESSAGE = """\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style type="text/css">
|
||||
* { /* Reset the worst style breakers */
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html { /* We always want at least this height */
|
||||
min-height: 100%%;
|
||||
}
|
||||
|
||||
body#error {
|
||||
font-family: sans-serif;
|
||||
height: 100%%;
|
||||
background: #3378c6;
|
||||
background: -webkit-radial-gradient(center, ellipse cover, #3378c6 0%%,#23538a 100%%);
|
||||
background: radial-gradient(ellipse at center, #3378c6 0%%,#23538a 100%%);
|
||||
background-size: 100%% 100%%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
#error #message {
|
||||
position: absolute;
|
||||
width: 34em;
|
||||
height: 4em;
|
||||
|
||||
top: 50%%;
|
||||
margin-top: -2em;
|
||||
left: 50%%;
|
||||
margin-left: -17em;
|
||||
|
||||
text-align: center;
|
||||
color: #114;
|
||||
text-shadow: 0 1px 0 #88d;
|
||||
}
|
||||
</style>
|
||||
<title>%(code)d - %(message)s</title>
|
||||
</head>
|
||||
<body id='error'>
|
||||
<div id='message'>
|
||||
<h1>%(message)s</h1>
|
||||
<span>%(explain)s</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
"""
|
||||
This request handler routes requests to a specialised handler.
|
||||
|
||||
Handling a request is roughly done in two steps:
|
||||
1) Requests are first passed through matching registered filters
|
||||
2) Request is passed to the matching handler.
|
||||
|
||||
Responsibility for selecting the handler is left to the server class.
|
||||
"""
|
||||
error_message_format = DEFAULT_ERROR_MESSAGE
|
||||
server_version = 'EcaHTTP/2'
|
||||
default_request_version = 'HTTP/1.1'
|
||||
|
||||
def send_header(self, key, value):
|
||||
"""Buffer headers until they can be sent."""
|
||||
if not self.response_sent:
|
||||
if not hasattr(self, '_cached_headers'):
|
||||
self._cached_headers = []
|
||||
self._cached_headers.append((key,value))
|
||||
else:
|
||||
super().send_header(key, value)
|
||||
|
||||
def send_response(self, *args, **kwargs):
|
||||
"""Sends the necessary response, and appends buffered headers."""
|
||||
super().send_response(*args, **kwargs)
|
||||
self.response_sent = True
|
||||
if hasattr(self, '_cached_headers'):
|
||||
for h in self._cached_headers:
|
||||
self.send_header(*h)
|
||||
self._cached_headers = []
|
||||
|
||||
def dispatch(self):
|
||||
"""Dispatch incoming requests."""
|
||||
self.handler = None
|
||||
self.response_sent = False
|
||||
|
||||
# the method we will be looking for
|
||||
# (uses HTTP method name to build Python method name)
|
||||
method_name = "handle_{}".format(self.command)
|
||||
|
||||
# let server determine specialised handler factory, and call it
|
||||
handler_factory = self.server.get_handler(self.command, self.path)
|
||||
if not handler_factory:
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
# instantiate handler
|
||||
self.handler = handler_factory(self)
|
||||
|
||||
# check for necessary HTTP method
|
||||
if not hasattr(self.handler, method_name):
|
||||
self.send_error(501, "Unsupported method ({})".format(self.command))
|
||||
return
|
||||
|
||||
# apply filters to request
|
||||
# note: filters are applied in order of registration
|
||||
for filter_factory in self.server.get_filters(self.command, self.path):
|
||||
filter = filter_factory(self)
|
||||
if not hasattr(filter, method_name):
|
||||
self.send_error(501, "Unsupported method ({})".format(self.command))
|
||||
return
|
||||
filter_method = getattr(filter, method_name)
|
||||
filter_method()
|
||||
|
||||
# select and invoke actual method
|
||||
method = getattr(self.handler, method_name)
|
||||
method()
|
||||
|
||||
def translate_path(self, path):
|
||||
"""
|
||||
Translate a /-separated PATH to the local filename syntax.
|
||||
|
||||
This method is unelegantly 'borrowed' from SimpleHTTPServer.py to change
|
||||
the original so that it has the `path = self.server.static_path' line.
|
||||
"""
|
||||
# abandon query parameters
|
||||
path = path.split('?',1)[0]
|
||||
path = path.split('#',1)[0]
|
||||
path = path[len(self.url_path):]
|
||||
# Don't forget explicit trailing slash when normalizing. Issue17324
|
||||
trailing_slash = path.rstrip().endswith('/')
|
||||
path = posixpath.normpath(urllib.parse.unquote(path))
|
||||
words = path.split('/')
|
||||
words = filter(None, words)
|
||||
# server content from static_path, instead of os.getcwd()
|
||||
path = self.local_path
|
||||
for word in words:
|
||||
drive, word = os.path.splitdrive(word)
|
||||
head, word = os.path.split(word)
|
||||
if word in (os.curdir, os.pardir): continue
|
||||
path = os.path.join(path, word)
|
||||
if trailing_slash:
|
||||
path += '/'
|
||||
return path
|
||||
|
||||
# Standard HTTP verbs bound to dispatch method
|
||||
def do_GET(self): self.dispatch()
|
||||
def do_POST(self): self.dispatch()
|
||||
def do_PUT(self): self.dispatch()
|
||||
def do_DELETE(self): self.dispatch()
|
||||
def do_HEAD(self): self.dispatch()
|
||||
|
||||
# Fallback handlers for static content
|
||||
# (These invoke the original SimpleHTTPRequestHandler behaviour)
|
||||
def handle_GET(self): super().do_GET()
|
||||
def handle_HEAD(self): super().do_HEAD()
|
||||
|
||||
# handle logging
|
||||
def _log_data(self):
|
||||
path = getattr(self, 'path','<unknown path>')
|
||||
command = getattr(self, 'command', '<unknown command>')
|
||||
return {
|
||||
'address': self.client_address,
|
||||
'location': path,
|
||||
'method': command
|
||||
}
|
||||
|
||||
def _get_message_format(self, format, args):
|
||||
log_data = self._log_data()
|
||||
message_format = "[{}, {} {}] {}".format(self.client_address[0],
|
||||
log_data['method'],
|
||||
log_data['location'],
|
||||
format%args)
|
||||
return message_format
|
||||
|
||||
#overload logging methods
|
||||
def log_message(self, format, *args):
|
||||
logger.debug(self._get_message_format(format, args), extra=self._log_data())
|
||||
|
||||
def log_error(self, format, *args):
|
||||
logger.warn(self._get_message_format(format, args), extra=self._log_data())
|
||||
|
||||
HandlerRegistration = namedtuple('HandlerRegistration',['methods','path','handler'])
|
||||
|
||||
class HTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
"""
|
||||
HTTP Server with path/method registration functionality to allow simple
|
||||
configuration of served content.
|
||||
"""
|
||||
def __init__(self, server_address, RequestHandlerClass=HTTPRequestHandler):
|
||||
self.handlers = []
|
||||
self.filters = []
|
||||
super().__init__(server_address, RequestHandlerClass)
|
||||
|
||||
def get_handler(self, method, path):
|
||||
"""Selects the best matching handler."""
|
||||
# Select handlers for the given method, that match any path or a prefix of the given path
|
||||
matches = [m
|
||||
for m
|
||||
in self.handlers
|
||||
if (not m.methods or method in m.methods) and path.startswith(m.path)]
|
||||
|
||||
# if there are matches, we select the one with the longest matching prefix
|
||||
if matches:
|
||||
best = max(matches, key=lambda e: len(e.path))
|
||||
return best.handler
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_filters(self, method, path):
|
||||
"""Selects all applicable filters."""
|
||||
# Select all filters that the given method, that match any path or a suffix of the given path
|
||||
return [f.handler
|
||||
for f
|
||||
in self.filters
|
||||
if (not f.methods or method in f.methods) and path.startswith(f.path)]
|
||||
|
||||
def _log_registration(self, kind, registration):
|
||||
message_format = "Adding HTTP request {} {} for ({} {})"
|
||||
message = message_format.format(kind,
|
||||
registration.handler,
|
||||
registration.methods,
|
||||
registration.path)
|
||||
logger.info(message)
|
||||
|
||||
def add_route(self, path, handler_factory, methods=["GET"]):
|
||||
"""
|
||||
Adds a request handler to the server.
|
||||
|
||||
The handler can be specialised in in or more request methods by
|
||||
providing a comma separated list of methods. Handlers are matched
|
||||
longest-matching-prefix with regards to paths.
|
||||
"""
|
||||
reg = HandlerRegistration(methods, path, handler_factory)
|
||||
self._log_registration('handler', reg)
|
||||
self.handlers.append(reg)
|
||||
|
||||
def add_content(self, path, local_path, methods=['GET','HEAD']):
|
||||
"""
|
||||
Adds a StaticContent handler to the server.
|
||||
|
||||
This method is shorthand for
|
||||
self.add_route(path, StaticContent(path, local_path), methods)
|
||||
"""
|
||||
if not path.endswith('/'):
|
||||
logger.warn("Static content configured without trailing '/'. "+
|
||||
"This is different from traditional behaviour.")
|
||||
logger.info("Serving static content for {} under '{}' from '{}'".format(methods,path,local_path))
|
||||
self.add_route(path, StaticContent(path, local_path), methods)
|
||||
|
||||
def add_filter(self, path, filter_factory, methods=[]):
|
||||
"""
|
||||
Adds a filter to the server.
|
||||
|
||||
Like handlers, filters can be specialised on in or more request methods
|
||||
by providing a comma-separated list of methods. Filters are selected on
|
||||
match prefix with regards to paths.
|
||||
|
||||
Filters are applied in order of registration.
|
||||
"""
|
||||
reg = HandlerRegistration(methods, path, filter_factory)
|
||||
self._log_registration('filter', reg)
|
||||
self.filters.append(reg)
|
||||
|
||||
def serve_forever(self):
|
||||
logger.info("Server is running...")
|
||||
super().serve_forever()
|
||||
|
||||
|
||||
class Handler:
|
||||
"""
|
||||
Handler base class.
|
||||
"""
|
||||
def __init__(self, request):
|
||||
self.request = request
|
||||
|
||||
|
||||
class Filter(Handler):
|
||||
"""
|
||||
Filter base class that does nothing.
|
||||
"""
|
||||
def handle_GET(self): self.handle()
|
||||
def handle_POST(self): self.handle()
|
||||
def handle_HEAD(self): self.handle()
|
||||
def handle(self):
|
||||
pass
|
||||
|
||||
|
||||
# static content handler defined here, because of intrinsice coupling with
|
||||
# request handler.
|
||||
def StaticContent(url_path, local_path):
|
||||
class StaticContent(Handler):
|
||||
"""
|
||||
Explicit fallback handler.
|
||||
"""
|
||||
def set_paths(self):
|
||||
self.request.local_path = local_path
|
||||
self.request.url_path = url_path
|
||||
|
||||
def handle_GET(self):
|
||||
self.set_paths()
|
||||
self.request.handle_GET()
|
||||
|
||||
def handle_HEAD(self):
|
||||
self.set_paths()
|
||||
self.request.handle_HEAD()
|
||||
|
||||
# return class so that it can be constructed
|
||||
return StaticContent
|
||||
|
||||
50
eca/pubsub.py
Normal file
50
eca/pubsub.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import collections
|
||||
import threading
|
||||
|
||||
|
||||
class PubSubChannel:
|
||||
"""
|
||||
Publish/Subscribe channel used for distribution of events.
|
||||
|
||||
The operations on this channel are thread-safe, but subscribers
|
||||
are executed by the publishing thread. Use a queue to decouple the
|
||||
publishing thread from the consuming thread.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.subscriptions = collections.defaultdict(list)
|
||||
|
||||
def subscribe(self, target, event='message'):
|
||||
"""
|
||||
Subscribe to an event.
|
||||
|
||||
The optional event name can be used to subscribe selectively.
|
||||
"""
|
||||
with self.lock:
|
||||
self.subscriptions[event].append(target)
|
||||
|
||||
def unsubscribe(self, target, event='message'):
|
||||
"""
|
||||
Unsubscribe from an event.
|
||||
|
||||
The optional event name can be used to unsubscribe from another event.
|
||||
"""
|
||||
with self.lock:
|
||||
self.subscriptions[event].remove(target)
|
||||
|
||||
def publish(self, event='message', data=None, delay=None):
|
||||
"""
|
||||
Publishes an event.
|
||||
|
||||
The event can be accompanied by optional data. A delay can be set to
|
||||
delay the publish action by the given amount of seconds.
|
||||
"""
|
||||
if delay is None:
|
||||
with self.lock:
|
||||
for target in self.subscriptions[event]:
|
||||
target(event, data)
|
||||
else:
|
||||
def task():
|
||||
self.publish(event, data)
|
||||
threading.Timer(delay, task).start()
|
||||
128
eca/sessions.py
Normal file
128
eca/sessions.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from http.cookies import SimpleCookie
|
||||
from collections import namedtuple
|
||||
from collections.abc import Mapping
|
||||
from itertools import product, chain
|
||||
import time
|
||||
import random
|
||||
|
||||
from . import httpd
|
||||
from . import Context, context_activate
|
||||
|
||||
|
||||
# Name generation for contexts and sessions
|
||||
def name_parts():
|
||||
"""
|
||||
This generator will create an endless list of steadily increasing
|
||||
name part lists.
|
||||
"""
|
||||
# name parts
|
||||
letters = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta',
|
||||
'theta', 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'omicron',
|
||||
'pi', 'rho', 'sigma', 'tau', 'upsilon', 'phi', 'chi', 'psi',
|
||||
'omega']
|
||||
colours = ['red', 'orange', 'yellow', 'green', 'blue', 'violet']
|
||||
|
||||
# randomize order
|
||||
random.shuffle(letters)
|
||||
random.shuffle(colours)
|
||||
|
||||
# yield initial sequence (letter-colour)
|
||||
parts = [letters, colours]
|
||||
yield parts
|
||||
|
||||
# forever generate longer sequences by appending the letter list
|
||||
# over and over. Note that this is the *same* letter list, so it will have
|
||||
# the exact order.
|
||||
while True:
|
||||
random.shuffle(letters)
|
||||
random.shuffle(colours)
|
||||
parts.append(letters)
|
||||
yield parts
|
||||
|
||||
# construct an iterator that will endlessly generate names:
|
||||
# 1) for each parts list p in name_parts() we take the cartesian product
|
||||
# 2) the product iterators are generated by the for...in generator
|
||||
# 3) we chain these iterators so that when the first is exhausted, we can
|
||||
# continue with the second, etc.
|
||||
# 4) we map the function '-'.join over the list of parts from the chain
|
||||
names = map('-'.join, chain.from_iterable((product(*p) for p in name_parts())))
|
||||
|
||||
|
||||
class SessionCookie(httpd.Filter):
|
||||
"""
|
||||
The actual HTTP filter that will apply the cookie handling logic to each
|
||||
request. This filter defers to the SessionManager with respect to the
|
||||
cookie name to use and the activation of sessions.
|
||||
"""
|
||||
def bind(self, manager):
|
||||
"""Post constructor configuration of filter."""
|
||||
self.manager = manager
|
||||
|
||||
def handle(self):
|
||||
"""
|
||||
Determine if a cookie needs to be set and let the session manager
|
||||
handle activation.
|
||||
"""
|
||||
cookies = self.request.cookies
|
||||
morsel = cookies.get(self.manager.cookie)
|
||||
|
||||
if not morsel:
|
||||
# Determine new cookie
|
||||
value = self.manager.generate_name()
|
||||
|
||||
# Set new cookie
|
||||
cookies[self.manager.cookie] = value
|
||||
cookies[self.manager.cookie]['path'] = '/'
|
||||
|
||||
# Send the new cookie as header
|
||||
self.request.send_header('Set-Cookie', cookies[self.manager.cookie].OutputString())
|
||||
else:
|
||||
value = morsel.value
|
||||
|
||||
self.manager.activate(value)
|
||||
|
||||
|
||||
class Session:
|
||||
"""
|
||||
The Session bookkeeping data.
|
||||
"""
|
||||
def __init__(self, context, seen):
|
||||
self.context = context
|
||||
self.seen = seen
|
||||
|
||||
def activate(self):
|
||||
"""Activate the session. Updates last seen time."""
|
||||
self.seen = time.time()
|
||||
context_activate(self.context)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
The SessionManager class. This class is callable so it can be used in place
|
||||
of a constructor in the configuration.
|
||||
"""
|
||||
def __init__(self, cookie_name):
|
||||
self.sessions = {}
|
||||
self.cookie = cookie_name
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
handler = SessionCookie(*args, **kwargs)
|
||||
handler.bind(self)
|
||||
return handler
|
||||
|
||||
def generate_name(self):
|
||||
result = next(names)
|
||||
while result in self.sessions:
|
||||
result = next(names)
|
||||
return result
|
||||
|
||||
def _new_session(self, name):
|
||||
result = Session(Context(name=name, init_data={'name': name}), time.time())
|
||||
result.context.start()
|
||||
return result
|
||||
|
||||
def activate(self, name):
|
||||
if name not in self.sessions:
|
||||
self.sessions[name] = self._new_session(name)
|
||||
self.sessions[name].activate()
|
||||
|
||||
67
eca/sse.py
Normal file
67
eca/sse.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import queue
|
||||
from collections import namedtuple
|
||||
|
||||
from . import httpd
|
||||
|
||||
PendingEvent = namedtuple('PendingEvent', ['data', 'name', 'id'])
|
||||
|
||||
class ServerSideEvents(httpd.Handler):
|
||||
"""
|
||||
Base class for server side events. See the specification of the W3C
|
||||
at http://dev.w3.org/html5/eventsource/
|
||||
|
||||
This class handles decoupling through the default Queue. Events can be
|
||||
posted for transmission by using send_event.
|
||||
"""
|
||||
|
||||
def __init__(self, request):
|
||||
super().__init__(request)
|
||||
self.queue = queue.Queue()
|
||||
|
||||
def send_event(self, data, name=None, id=None):
|
||||
self.queue.put(PendingEvent(data, name, id))
|
||||
|
||||
def go_subscribe(self):
|
||||
pass
|
||||
|
||||
def go_unsubscribe(self):
|
||||
pass
|
||||
|
||||
def handle_GET(self):
|
||||
self.go_subscribe()
|
||||
|
||||
# Send HTTP headers:
|
||||
self.request.send_response(200)
|
||||
self.request.send_header("Content-type", "text/event-stream")
|
||||
self.request.end_headers()
|
||||
|
||||
done = False
|
||||
while not done:
|
||||
event = self.queue.get()
|
||||
if event == None:
|
||||
done = True
|
||||
else:
|
||||
done = not self._send_message(event)
|
||||
|
||||
self.go_unsubscribe()
|
||||
|
||||
def _send_message(self, event):
|
||||
try:
|
||||
if event.id is not None:
|
||||
id_line = "id: {}\n".format(event.id)
|
||||
self.request.wfile.write(id_line.encode('utf-8'))
|
||||
|
||||
if event.name is not None:
|
||||
event_line = "event: {}\n".format(event.name)
|
||||
self.request.wfile.write(event_line.encode('utf-8'))
|
||||
|
||||
data_line = "data: {}\n".format(event.data)
|
||||
self.request.wfile.write(data_line.encode('utf-8'))
|
||||
|
||||
self.request.wfile.write("\n".encode('utf-8'))
|
||||
self.request.wfile.flush()
|
||||
|
||||
return True
|
||||
except IOError:
|
||||
return False
|
||||
|
||||
39
eca/util.py
Normal file
39
eca/util.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os.path
|
||||
|
||||
|
||||
class NamespaceError(KeyError):
|
||||
"""Exception raised for errors in the NamespaceDict."""
|
||||
pass
|
||||
|
||||
|
||||
class NamespaceDict(dict):
|
||||
"""
|
||||
A dictionary that also allows access through attributes.
|
||||
|
||||
See http://docs.python.org/3.3/reference/datamodel.html#customizing-attribute-access
|
||||
"""
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name not in self:
|
||||
raise NamespaceError(name)
|
||||
return self[name]
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
self[name] = value
|
||||
|
||||
def __delattr__(self, name):
|
||||
del self[name]
|
||||
|
||||
|
||||
def describe_function(fn):
|
||||
"""
|
||||
Generates a human readable reference to the given function.
|
||||
|
||||
This function is most useful when used on function defined in actual files.
|
||||
"""
|
||||
parts = []
|
||||
parts.append(fn.__name__)
|
||||
|
||||
parts.append(" ({}:{})".format(os.path.relpath(fn.__code__.co_filename), fn.__code__.co_firstlineno))
|
||||
|
||||
return ''.join(parts)
|
||||
150
neca.py
Executable file
150
neca.py
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import threading
|
||||
import importlib
|
||||
import os.path
|
||||
import sys
|
||||
import logging
|
||||
import json
|
||||
|
||||
from eca import *
|
||||
import eca.httpd
|
||||
import eca.http
|
||||
|
||||
# logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hr_items(seq):
|
||||
"""Creates a more readable comma-separated list of things."""
|
||||
return ', '.join("'{}'".format(e) for e in seq)
|
||||
|
||||
|
||||
def log_level(level):
|
||||
"""argparse type to allow log level to be set directly."""
|
||||
numeric_level = getattr(logging, level.upper(), None)
|
||||
if not isinstance(numeric_level, int):
|
||||
message_template = "'{}' is not a valid logging level. Choose from {}"
|
||||
message = message_template.format(level, _hr_items(log_level.allowed))
|
||||
raise argparse.ArgumentTypeError(message)
|
||||
return numeric_level
|
||||
|
||||
|
||||
# the following are allowed names for log levels
|
||||
log_level.allowed = ['debug', 'info', 'warning', 'error', 'critical']
|
||||
|
||||
|
||||
def main_server(args, rules_module):
|
||||
"""HTTP server entry point."""
|
||||
# determine initial static content path
|
||||
rules_path = os.path.dirname(os.path.abspath(rules_module.__file__))
|
||||
rules_file = os.path.basename(os.path.abspath(rules_module.__file__))
|
||||
rules_file, rules_ext = os.path.splitext(rules_file)
|
||||
root_path = os.path.join(rules_path, "{}_static".format(rules_file))
|
||||
|
||||
# see if an override has been given (absolute or relative)
|
||||
if hasattr(rules_module, 'root_content_path'):
|
||||
if os.path.isabs(rules_module.root_content_path):
|
||||
root_path = rules_module.root_content_path
|
||||
else:
|
||||
root_path = os.path.join(rules_path, rules_module.root_content_path)
|
||||
|
||||
# configure http server
|
||||
httpd = eca.httpd.HTTPServer((args.ip, args.port))
|
||||
|
||||
# default root route
|
||||
httpd.add_content('/', root_path)
|
||||
|
||||
# default events route
|
||||
httpd.add_route('/events', eca.http.EventStream)
|
||||
|
||||
# default handlers for cookies and sessions
|
||||
httpd.add_filter('/', eca.http.Cookies)
|
||||
httpd.add_filter('/', eca.http.SessionManager('eca-session'))
|
||||
|
||||
# invoke module specific configuration
|
||||
if hasattr(rules_module, 'add_request_handlers'):
|
||||
rules_module.add_request_handlers(httpd)
|
||||
|
||||
# start serving
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
def main_engine(args, rules_module):
|
||||
"""
|
||||
Rules engine only entry point.
|
||||
"""
|
||||
# create context
|
||||
context = Context(init_data={'name': '__main__'})
|
||||
context.start(daemon=False)
|
||||
|
||||
# attach printing emit listener to context
|
||||
def emitter(name, event):
|
||||
print("emit '{}': {}".format(event.name, json.loads(event.get('json'))))
|
||||
context.channel.subscribe(emitter, 'emit')
|
||||
|
||||
# fire main event
|
||||
with context_switch(context):
|
||||
logger.info("Starting module '{}'...".format(args.file))
|
||||
fire('main')
|
||||
# then read each line and process
|
||||
for line in sys.stdin:
|
||||
fire('line', line)
|
||||
fire('end-of-input')
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main program entry point.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description='The Neca HTTP server.')
|
||||
parser.set_defaults(entry_point=main_engine)
|
||||
parser.add_argument('file',
|
||||
default='simple.py',
|
||||
help="The rules file to load (defaults to %(default)s).",
|
||||
nargs='?')
|
||||
parser.add_argument('-t', '--trace',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Trace the execution of rules.')
|
||||
parser.add_argument('-l', '--log',
|
||||
default='warning',
|
||||
help="The log level to use. One of {} (defaults to '%(default)s')".format(_hr_items(log_level.allowed)),
|
||||
metavar='LEVEL',
|
||||
type=log_level)
|
||||
parser.add_argument('-s', '--server',
|
||||
dest='entry_point',
|
||||
action='store_const',
|
||||
const=main_server,
|
||||
help='Start HTTP server instead of directly executing the module.')
|
||||
parser.add_argument('-p', '--port',
|
||||
default=8080,
|
||||
help="The port to bind the HTTP server to (default to '%(default)s')",
|
||||
type=int)
|
||||
parser.add_argument('-i', '--ip',
|
||||
default='localhost',
|
||||
help="The IP to bind the HTTP server to (defaults to '%(default)s'")
|
||||
args = parser.parse_args()
|
||||
|
||||
# set logging level
|
||||
logging.basicConfig(level=args.log)
|
||||
|
||||
# enable trace logger if requested
|
||||
if args.trace:
|
||||
logging.getLogger('trace').setLevel(logging.DEBUG)
|
||||
|
||||
# load module
|
||||
rules_dir, rules_file = os.path.split(args.file)
|
||||
rules_name = os.path.splitext(rules_file)[0]
|
||||
|
||||
old_path = list(sys.path)
|
||||
sys.path.insert(0, rules_dir)
|
||||
try:
|
||||
rules_module = importlib.import_module(rules_name)
|
||||
finally:
|
||||
sys.path[:] = old_path
|
||||
|
||||
args.entry_point(args, rules_module)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2036
p2000.json
2036
p2000.json
File diff suppressed because it is too large
Load Diff
105
sports.json
Normal file
105
sports.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"created_at":"Sat Nov 16 12:51:41 +0000 2019",
|
||||
"id":1195685871556710402,
|
||||
"id_str":"1195685871556710402",
|
||||
"text":"@BobGreenburg @ONeill_Coffee Congrats to the excellent football program at Wilmington. One classy organization!",
|
||||
"display_text_range":[
|
||||
29,
|
||||
111
|
||||
],
|
||||
"source":"<a href=\"http:\/\/twitter.com\/download\/iphone\" rel=\"nofollow\">Twitter for iPhone<\/a>",
|
||||
"truncated":false,
|
||||
"in_reply_to_status_id":1195545633685475333,
|
||||
"in_reply_to_status_id_str":"1195545633685475333",
|
||||
"in_reply_to_user_id":483881032,
|
||||
"in_reply_to_user_id_str":"483881032",
|
||||
"in_reply_to_screen_name":"BobGreenburg",
|
||||
"user":{
|
||||
"id":774718832212606976,
|
||||
"id_str":"774718832212606976",
|
||||
"name":"Express Youngstown",
|
||||
"screen_name":"ExpressProsYO",
|
||||
"location":"5815 Market Street",
|
||||
"url":"http:\/\/apply.expresspros.com\/",
|
||||
"description":"Express Employment Professionals is one of the top staffing companies in the U.S. and Canada. WE want to help YOU succeed and find a job that you love!",
|
||||
"translator_type":"none",
|
||||
"protected":false,
|
||||
"verified":false,
|
||||
"followers_count":19,
|
||||
"friends_count":136,
|
||||
"listed_count":0,
|
||||
"favourites_count":474,
|
||||
"statuses_count":218,
|
||||
"created_at":"Sat Sep 10 21:18:58 +0000 2016",
|
||||
"utc_offset":null,
|
||||
"time_zone":null,
|
||||
"geo_enabled":false,
|
||||
"lang":null,
|
||||
"contributors_enabled":false,
|
||||
"is_translator":false,
|
||||
"profile_background_color":"F5F8FA",
|
||||
"profile_background_image_url":"",
|
||||
"profile_background_image_url_https":"",
|
||||
"profile_background_tile":false,
|
||||
"profile_link_color":"1DA1F2",
|
||||
"profile_sidebar_border_color":"C0DEED",
|
||||
"profile_sidebar_fill_color":"DDEEF6",
|
||||
"profile_text_color":"333333",
|
||||
"profile_use_background_image":true,
|
||||
"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/775167844921188353\/fWquHsOK_normal.jpg",
|
||||
"profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/775167844921188353\/fWquHsOK_normal.jpg",
|
||||
"profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/774718832212606976\/1473649631",
|
||||
"default_profile":true,
|
||||
"default_profile_image":false,
|
||||
"following":null,
|
||||
"follow_request_sent":null,
|
||||
"notifications":null
|
||||
},
|
||||
"geo":null,
|
||||
"coordinates":null,
|
||||
"place":null,
|
||||
"contributors":null,
|
||||
"is_quote_status":false,
|
||||
"quote_count":0,
|
||||
"reply_count":0,
|
||||
"retweet_count":0,
|
||||
"favorite_count":0,
|
||||
"entities":{
|
||||
"hashtags":[
|
||||
|
||||
],
|
||||
"urls":[
|
||||
|
||||
],
|
||||
"user_mentions":[
|
||||
{
|
||||
"screen_name":"BobGreenburg",
|
||||
"name":"Bob Greenburg",
|
||||
"id":483881032,
|
||||
"id_str":"483881032",
|
||||
"indices":[
|
||||
0,
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"screen_name":"ONeill_Coffee",
|
||||
"name":"O'NeillCoffeeCompany",
|
||||
"id":2804543925,
|
||||
"id_str":"2804543925",
|
||||
"indices":[
|
||||
14,
|
||||
28
|
||||
]
|
||||
}
|
||||
],
|
||||
"symbols":[
|
||||
|
||||
]
|
||||
},
|
||||
"favorited":false,
|
||||
"retweeted":false,
|
||||
"filter_level":"low",
|
||||
"lang":"en",
|
||||
"timestamp_ms":"1573908701205"
|
||||
}
|
||||
39
template.py
Normal file
39
template.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from eca import *
|
||||
|
||||
import random
|
||||
|
||||
## You might have to update the root path to point to the correct path
|
||||
## (by default, it points to <rules>_static)
|
||||
# root_content_path = 'template_static'
|
||||
|
||||
|
||||
# binds the 'setup' function as the action for the 'init' event
|
||||
# the action will be called with the context and the event
|
||||
@event('init')
|
||||
def setup(ctx, e):
|
||||
ctx.count = 0
|
||||
fire('sample', {'previous': 0.0})
|
||||
|
||||
|
||||
# define a normal Python function
|
||||
def clip(lower, value, upper):
|
||||
return max(lower, min(value, upper))
|
||||
|
||||
@event('sample')
|
||||
def generate_sample(ctx, e):
|
||||
ctx.count += 1
|
||||
if ctx.count % 50 == 0:
|
||||
emit('debug', {'text': 'Log message #'+str(ctx.count)+'!'})
|
||||
|
||||
# base sample on previous one
|
||||
sample = clip(-100, e.data['previous'] + random.uniform(+5.0, -5.0), 100)
|
||||
|
||||
# emit to outside world
|
||||
emit('sample',{
|
||||
'action': 'add',
|
||||
'value': sample
|
||||
})
|
||||
|
||||
# chain event
|
||||
fire('sample', {'previous': sample}, delay=0.05)
|
||||
|
||||
64
template_static/index.html
Normal file
64
template_static/index.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Neca Test</title>
|
||||
<link rel="stylesheet" href="/style/layout.css"/>
|
||||
<link rel="stylesheet" href="/style/theme.css"/>
|
||||
<script src="/lib/jquery-2.1.1.min.js"></script>
|
||||
<script src="/lib/jquery.flot.min.js"></script>
|
||||
<script src="/lib/core.js"></script>
|
||||
<script src="/lib/charts.js"></script>
|
||||
<script src="/lib/log.js"></script>
|
||||
</head>
|
||||
<body class="container_12">
|
||||
<h1>ECA Dashboard Template</h1>
|
||||
|
||||
<div class="grid_6 vert_4">
|
||||
<p>This is the dashboard template file. The easiest way to get started is to think up a simple name (let's say we take 'dashboard'). Now copy <code>template.py</code> to <code>{name}.py</code> start a new module (so that's <code>dashboard.py</code>) and copy <code>template_static</code> to <code>{name}_static</code>.
|
||||
<p>Now you can run the new project with: <pre>python neca.py -s {name}.py</pre>
|
||||
<p>Further documentation on the ECA system can be found at <a href="https://github.com/utwente-db/eca/wiki">github.com/utwente-db/eca/wiki</a>, and demos can be found in the <code>demos/</code> directory.
|
||||
</div>
|
||||
<div class="grid_6 vert_4">
|
||||
<p>In the sample <code>template.py</code> (which comes with the dashboard you're looking at right now), you will find the rules that power this example.
|
||||
<p>Rules are written in <a href="https://www.python.org/">Python</a> and work as follows:
|
||||
<pre>@event("foo")
|
||||
def action(context, event):
|
||||
print("Event " + event.name + "!")
|
||||
</pre>
|
||||
The <code>@event</code> part tells the system to fire the action whenever the event 'foo' occurs. The <code>def action(context, event):</code> part defines a new action that takes two arguments: the context and the event. The rest of the code is the action body.
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
<div class="grid_4">
|
||||
<p>The graph to the right is continuously filled with data generated by the rules.
|
||||
<p>In <code>template.py</code> you can see that an event called 'sample' is fired again and again to create new data points for the graph.
|
||||
<p>These points are then sent to the browser with:
|
||||
<pre>emit('sample',{
|
||||
'action': 'add',
|
||||
'value': sample
|
||||
})</pre>
|
||||
|
||||
</div>
|
||||
<div id="graph" class="grid_8 vert_4"></div>
|
||||
|
||||
<script>
|
||||
// create a rolling chart block
|
||||
block('#graph').rolling_chart({
|
||||
memory: 150,
|
||||
chart: {
|
||||
yaxis: {
|
||||
min: -100,
|
||||
max: 100
|
||||
},
|
||||
xaxis: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// connect sample event to graph
|
||||
events.connect('sample', '#graph');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
368
template_static/lib/charts.js
Normal file
368
template_static/lib/charts.js
Normal file
@@ -0,0 +1,368 @@
|
||||
(function($, block) {
|
||||
|
||||
// a simple rolling chart with memory
|
||||
block.fn.rolling_chart = function(config) {
|
||||
// combine default configuration with user configuration
|
||||
var options = $.extend({
|
||||
memory: 100,
|
||||
// required!!
|
||||
series: {
|
||||
'default': {data: []}
|
||||
},
|
||||
// flot initialization options
|
||||
options: {
|
||||
xaxis: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
}, config);
|
||||
|
||||
// maintain state for this block
|
||||
var data = {};
|
||||
for(var k in options.series) {
|
||||
data[k] = (options.series[k].data || []).slice();
|
||||
}
|
||||
|
||||
// function to project our state to something the library understands
|
||||
var prepare_data = function() {
|
||||
var result = [];
|
||||
|
||||
// process each series
|
||||
for(var k in data) {
|
||||
var series = data[k];
|
||||
var points = [];
|
||||
|
||||
// create point pairs and gap values
|
||||
for(var i in series) {
|
||||
if(series[i] == null) {
|
||||
points.push(null);
|
||||
} else {
|
||||
points.push([i, series[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
// combine state data with series configuration by user
|
||||
result.push($.extend(options.series[k], {data: points}));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// initial setup of library state (also builds necessary HTML)
|
||||
var plot = $.plot(this.$element, prepare_data(), options.options);
|
||||
|
||||
|
||||
// register actions for this block
|
||||
this.actions({
|
||||
'add': function(e, message) {
|
||||
// if the 'value' field is used, update all series (useful with a single series)
|
||||
if(typeof message.values == 'undefined' && typeof message.value != 'undefined') {
|
||||
message.values = {}
|
||||
for(var k in options.series) {
|
||||
message.values[k] = message.value;
|
||||
}
|
||||
}
|
||||
|
||||
// update all series
|
||||
for(var k in options.series) {
|
||||
// roll memory
|
||||
if(data[k].length > options.memory) {
|
||||
data[k] = data[k].slice(1);
|
||||
}
|
||||
|
||||
// insert value or gap (in case of null)
|
||||
data[k].push(message.values[k]);
|
||||
}
|
||||
|
||||
// update HTML
|
||||
plot.setData(prepare_data());
|
||||
plot.setupGrid();
|
||||
plot.draw();
|
||||
}
|
||||
});
|
||||
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
// a simple linechart example
|
||||
block.fn.linechart = function(config) {
|
||||
var options = $.extend({
|
||||
// required
|
||||
series : {default:{}},
|
||||
// flot initialization options
|
||||
options : {}
|
||||
}, config);
|
||||
|
||||
// create empty linechart with parameter options
|
||||
var plot = $.plot(this.$element, [],options.options);
|
||||
|
||||
// dict containing the labels and values
|
||||
var linedata_series = {};
|
||||
var linedata_first;
|
||||
|
||||
var initline = function(series) {
|
||||
linedata_first = undefined;
|
||||
for(var k in series) {
|
||||
var si = series[k];
|
||||
si.data = [];
|
||||
linedata_series[k] = si;
|
||||
if ( linedata_first == undefined )
|
||||
linedata_first = si;
|
||||
}
|
||||
}
|
||||
|
||||
initline(options.series);
|
||||
|
||||
var addline = function(label, values) {
|
||||
var data;
|
||||
|
||||
if (linedata_series.hasOwnProperty(label))
|
||||
data = linedata_series[label].data;
|
||||
else
|
||||
data = linedata_first.data;
|
||||
for(var v in values) {
|
||||
data.push(values[v]);
|
||||
}
|
||||
redraw();
|
||||
}
|
||||
|
||||
var setline = function(label, values) {
|
||||
if (linedata_series.hasOwnProperty(label))
|
||||
linedata_series[label].data = values;
|
||||
else
|
||||
linedata_first.data = values;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var redraw = function() {
|
||||
var result = [];
|
||||
for(var k in linedata_series) {
|
||||
if (linedata_series.hasOwnProperty(k)) {
|
||||
var line_serie = linedata_series[k];
|
||||
|
||||
result.push({label:k,data:line_serie.data});
|
||||
}
|
||||
}
|
||||
plot.setData(result);
|
||||
plot.setupGrid();
|
||||
plot.draw();
|
||||
}
|
||||
|
||||
var reset = function() {
|
||||
initline(options.series);
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'set': function(e, message) {
|
||||
setline(message.series, message.value);
|
||||
},
|
||||
'add': function(e, message) {
|
||||
addline(message.series, message.value);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
// a simple barchart example
|
||||
block.fn.barchart = function(config) {
|
||||
var options = $.extend({
|
||||
filter_function : function(category,val,max) { return true; },
|
||||
// required
|
||||
series : { "default":{
|
||||
data: {},
|
||||
label: "default",
|
||||
bars: {
|
||||
show: true,
|
||||
barWidth: 0.2,
|
||||
align: "left"
|
||||
}
|
||||
|
||||
} },
|
||||
// flot initialization options
|
||||
options: { xaxis: {
|
||||
mode: "categories",
|
||||
tickLength: 0
|
||||
}}
|
||||
|
||||
}, config);
|
||||
|
||||
var bardata_series = options.series;
|
||||
var bardata_first;
|
||||
|
||||
for (bardata_first in bardata_series) break;
|
||||
|
||||
var translate_bar = function() {
|
||||
var result = [];
|
||||
for(var k in bardata_series) {
|
||||
if (bardata_series.hasOwnProperty(k)) {
|
||||
var newserie = jQuery.extend({}, bardata_series[k]);
|
||||
var newdata = [];
|
||||
var data = newserie.data;
|
||||
var max = 0;
|
||||
|
||||
for(var l in data) {
|
||||
if (data.hasOwnProperty(l)) {
|
||||
max = Math.max(max, data[l]);
|
||||
}
|
||||
}
|
||||
|
||||
for(var l in data) {
|
||||
if (data.hasOwnProperty(l)) {
|
||||
if ( options.filter_function(l,data[l],max) )
|
||||
newdata.push([l,data[l]]);
|
||||
}
|
||||
}
|
||||
newserie.data = newdata;
|
||||
result.push(newserie);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var plot = $.plot(this.$element, translate_bar(), options.options);
|
||||
|
||||
var addbar = function(serie_label, category, value) {
|
||||
var data;
|
||||
|
||||
if ( serie_label == undefined )
|
||||
data = bardata_series[bardata_first].data;
|
||||
else
|
||||
data = bardata_series[serie_label].data;
|
||||
if (data.hasOwnProperty(category))
|
||||
data[category] = (data[category] + value);
|
||||
else
|
||||
data[category] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var setbar = function(serie_label, category, value) {
|
||||
var data;
|
||||
|
||||
if ( serie_label == undefined )
|
||||
data = bardata_series[bardata_first].data;
|
||||
else
|
||||
data = bardata_series[serie_label].data;
|
||||
data[category] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var redraw = function() {
|
||||
plot.setData(translate_bar());
|
||||
plot.setupGrid();
|
||||
plot.draw();
|
||||
}
|
||||
|
||||
var reset = function() {
|
||||
for(var k in bardata_series) {
|
||||
if (bardata_series.hasOwnProperty(k)) {
|
||||
bardata_series[k].data = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'set': function(e, message) {
|
||||
setbar(message.series,message.value[0],message.value[1]);
|
||||
},
|
||||
'add': function(e, message) {
|
||||
addbar(message.series,message.value[0],message.value[1]);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
// a simple piechart example
|
||||
block.fn.piechart = function(config) {
|
||||
var options = $.extend({
|
||||
// see: http://www.flotcharts.org/flot/examples/series-pie/
|
||||
filter_function : function(category,val,max) { return true; },
|
||||
options : {
|
||||
series: {
|
||||
pie: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
// demo crashes with this option
|
||||
// legend: { show: false }
|
||||
}}, config);
|
||||
|
||||
// create empty piechart with parameter options
|
||||
var plot = $.plot(this.$element, [],options.options);
|
||||
|
||||
// dict containing the labels and values
|
||||
var piedata_dict = {};
|
||||
|
||||
var addpie = function(label, value) {
|
||||
if (piedata_dict.hasOwnProperty(label))
|
||||
piedata_dict[label] = (piedata_dict[label] + value);
|
||||
else
|
||||
piedata_dict[label] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var setpie = function(label, value) {
|
||||
piedata_dict[label] = value;
|
||||
redraw();
|
||||
}
|
||||
|
||||
var redraw = function() {
|
||||
var result = [];
|
||||
var max = 0;
|
||||
|
||||
for(var k in piedata_dict) {
|
||||
if (piedata_dict.hasOwnProperty(k)) {
|
||||
max = Math.max(max, piedata_dict[k]);
|
||||
}
|
||||
}
|
||||
for(var k in piedata_dict) {
|
||||
if (piedata_dict.hasOwnProperty(k)) {
|
||||
if ( options.filter_function(k,piedata_dict[k],max) )
|
||||
result.push({label:k,data:piedata_dict[k]});
|
||||
}
|
||||
}
|
||||
plot.setData(result);
|
||||
plot.setupGrid();
|
||||
plot.draw();
|
||||
}
|
||||
|
||||
var reset = function() {
|
||||
piedata_dict = {};
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'set': function(e, message) {
|
||||
setpie(message.value[0],message.value[1]);
|
||||
},
|
||||
'add': function(e, message) {
|
||||
addpie(message.value[0],message.value[1]);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
})(jQuery, block);
|
||||
105
template_static/lib/core.js
Normal file
105
template_static/lib/core.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Event stream handling.
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/API/EventSource for a more
|
||||
comprehensive explanation.
|
||||
*/
|
||||
|
||||
events = {};
|
||||
|
||||
(function($, exports) {
|
||||
var e = new EventSource('/events');
|
||||
|
||||
exports.connect = function(name, elements) {
|
||||
// wrap to allow selector, jQuery object and DOM nodes
|
||||
var $elements = $(elements);
|
||||
|
||||
// add listener that triggers events in DOM
|
||||
this.listen(name, function(message) {
|
||||
$elements.trigger('server-event', [message]);
|
||||
});
|
||||
};
|
||||
|
||||
exports.listen = function(name, callback) {
|
||||
// add event listener to event stream
|
||||
e.addEventListener(name, function(m) {
|
||||
try {
|
||||
var message = JSON.parse(m.data);
|
||||
} catch(err) {
|
||||
console.exception("Received malformed message: ",err);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(message);
|
||||
});
|
||||
};
|
||||
})(jQuery, events);
|
||||
|
||||
/*
|
||||
** Block fucntion allows for quick creation of new block types.
|
||||
*/
|
||||
(function($) {
|
||||
var ConstructionState = function($element) {
|
||||
this.$element = $element;
|
||||
|
||||
// transfer all block constructors to scope
|
||||
for(var b in block.fn) {
|
||||
// prevent overrides
|
||||
if(!(b in this)) {
|
||||
// reference block type in this object
|
||||
this[b] = block.fn[b];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ConstructionState.prototype.actions = function(actions_or_def, def) {
|
||||
// handle function overloading
|
||||
if(typeof actions_or_def == 'function') {
|
||||
def = actions_or_def;
|
||||
actions = {};
|
||||
} else {
|
||||
actions = actions_or_def;
|
||||
}
|
||||
|
||||
// default actionless handler
|
||||
if(typeof def == 'undefined') {
|
||||
def = function(e, message) {
|
||||
console.error("Received actionless server event." +
|
||||
" Did you forget to set an action field?");
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch all incoming server events
|
||||
this.$element.on('server-event', function(e, message) {
|
||||
if(!('action' in message)) {
|
||||
$(this).trigger('_default.server-event', [message]);
|
||||
} else {
|
||||
$(this).trigger(message.action+'.server-event', [message]);
|
||||
}
|
||||
});
|
||||
|
||||
// bind all actions
|
||||
this.$element.on('_default.server-event', def);
|
||||
|
||||
for(var k in actions) {
|
||||
this.$element.on(k+'.server-event', actions[k]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
block = function(elements) {
|
||||
// allow passing of selectors, jquery objects and DOM nodes
|
||||
var $element = $(elements);
|
||||
|
||||
// actual work
|
||||
if($element.length != 1) {
|
||||
console.error("Must have one element to create block for." +
|
||||
" Was given: '",elements,"'");
|
||||
return;
|
||||
}
|
||||
|
||||
return new ConstructionState($element);
|
||||
}
|
||||
|
||||
block.fn = {};
|
||||
})(jQuery);
|
||||
72
template_static/lib/form.js
Normal file
72
template_static/lib/form.js
Normal file
@@ -0,0 +1,72 @@
|
||||
(function($, block) {
|
||||
block.fn.form = function(config) {
|
||||
var options = $.extend({
|
||||
target: null,
|
||||
callback: function() {}
|
||||
}, config);
|
||||
|
||||
// see if we can grab the action from the form tag
|
||||
if(options.target === null) {
|
||||
var action = this.$element.find("form").attr('action');
|
||||
if(typeof action !== 'undefined') {
|
||||
options.target = action;
|
||||
}
|
||||
}
|
||||
|
||||
// check for sane config
|
||||
if(options.target === null) {
|
||||
console.log("The 'form' block requires a target option to know where to send the request.");
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
// set up submit handler
|
||||
var $block = this.$element;
|
||||
$block.find("form").submit(function(event) {
|
||||
var payload = {};
|
||||
|
||||
// handle simple fields
|
||||
$block.find("textarea[name], select[name]").each(function() {
|
||||
payload[this.name] = this.value;
|
||||
});
|
||||
|
||||
// handle the more complex fields
|
||||
$block.find("input[name]").each(function() {
|
||||
switch($(this).attr('type')) {
|
||||
// radio buttons usually have a single selected option per name
|
||||
case 'radio':
|
||||
if($(this).prop('checked')) {
|
||||
payload[this.name] = this.value;
|
||||
}
|
||||
break;
|
||||
|
||||
// checkboxes are akin to a bitfield
|
||||
case 'checkbox':
|
||||
// build a map of checked values for this name
|
||||
if(typeof payload[this.name] === 'undefined') {
|
||||
payload[this.name] = [];
|
||||
}
|
||||
if($(this).prop('checked')) {
|
||||
payload[this.name].push(this.value);
|
||||
}
|
||||
break;
|
||||
|
||||
// default to storing the value
|
||||
default:
|
||||
payload[this.name] = this.value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// fire and forget the datablob
|
||||
$.ajax(options.target,{
|
||||
method: 'POST',
|
||||
data: JSON.stringify(payload)
|
||||
}).then(options.callback);
|
||||
});
|
||||
|
||||
return this.$element;
|
||||
}
|
||||
|
||||
})(jQuery, block);
|
||||
232
template_static/lib/jqcloud-1.0.4.js
Normal file
232
template_static/lib/jqcloud-1.0.4.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/*!
|
||||
* jQCloud Plugin for jQuery
|
||||
*
|
||||
* Version 1.0.4
|
||||
*
|
||||
* Copyright 2011, Luca Ongaro
|
||||
* Licensed under the MIT license.
|
||||
*
|
||||
* Date: 2013-05-09 18:54:22 +0200
|
||||
*/
|
||||
|
||||
(function( $ ) {
|
||||
"use strict";
|
||||
$.fn.jQCloud = function(word_array, options) {
|
||||
// Reference to the container element
|
||||
var $this = this;
|
||||
// Namespace word ids to avoid collisions between multiple clouds
|
||||
var cloud_namespace = $this.attr('id') || Math.floor((Math.random()*1000000)).toString(36);
|
||||
|
||||
// Default options value
|
||||
var default_options = {
|
||||
width: $this.width(),
|
||||
height: $this.height(),
|
||||
center: {
|
||||
x: ((options && options.width) ? options.width : $this.width()) / 2.0,
|
||||
y: ((options && options.height) ? options.height : $this.height()) / 2.0
|
||||
},
|
||||
delayedMode: word_array.length > 50,
|
||||
shape: false, // It defaults to elliptic shape
|
||||
encodeURI: true,
|
||||
removeOverflowing: true
|
||||
};
|
||||
|
||||
options = $.extend(default_options, options || {});
|
||||
|
||||
// Add the "jqcloud" class to the container for easy CSS styling, set container width/height
|
||||
$this.addClass("jqcloud").width(options.width).height(options.height);
|
||||
|
||||
// Container's CSS position cannot be 'static'
|
||||
if ($this.css("position") === "static") {
|
||||
$this.css("position", "relative");
|
||||
}
|
||||
|
||||
var drawWordCloud = function() {
|
||||
// Helper function to test if an element overlaps others
|
||||
var hitTest = function(elem, other_elems) {
|
||||
// Pairwise overlap detection
|
||||
var overlapping = function(a, b) {
|
||||
if (Math.abs(2.0*a.offsetLeft + a.offsetWidth - 2.0*b.offsetLeft - b.offsetWidth) < a.offsetWidth + b.offsetWidth) {
|
||||
if (Math.abs(2.0*a.offsetTop + a.offsetHeight - 2.0*b.offsetTop - b.offsetHeight) < a.offsetHeight + b.offsetHeight) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
var i = 0;
|
||||
// Check elements for overlap one by one, stop and return false as soon as an overlap is found
|
||||
for(i = 0; i < other_elems.length; i++) {
|
||||
if (overlapping(elem, other_elems[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Make sure every weight is a number before sorting
|
||||
for (var i = 0; i < word_array.length; i++) {
|
||||
word_array[i].weight = parseFloat(word_array[i].weight, 10);
|
||||
}
|
||||
|
||||
// Sort word_array from the word with the highest weight to the one with the lowest
|
||||
word_array.sort(function(a, b) { if (a.weight < b.weight) {return 1;} else if (a.weight > b.weight) {return -1;} else {return 0;} });
|
||||
|
||||
var step = (options.shape === "rectangular") ? 18.0 : 2.0,
|
||||
already_placed_words = [],
|
||||
aspect_ratio = options.width / options.height;
|
||||
|
||||
// Function to draw a word, by moving it in spiral until it finds a suitable empty place. This will be iterated on each word.
|
||||
var drawOneWord = function(index, word) {
|
||||
// Define the ID attribute of the span that will wrap the word, and the associated jQuery selector string
|
||||
var word_id = cloud_namespace + "_word_" + index,
|
||||
word_selector = "#" + word_id,
|
||||
angle = 6.28 * Math.random(),
|
||||
radius = 0.0,
|
||||
|
||||
// Only used if option.shape == 'rectangular'
|
||||
steps_in_direction = 0.0,
|
||||
quarter_turns = 0.0,
|
||||
|
||||
weight = 5,
|
||||
custom_class = "",
|
||||
inner_html = "",
|
||||
word_span;
|
||||
|
||||
// Extend word html options with defaults
|
||||
word.html = $.extend(word.html, {id: word_id});
|
||||
|
||||
// If custom class was specified, put them into a variable and remove it from html attrs, to avoid overwriting classes set by jQCloud
|
||||
if (word.html && word.html["class"]) {
|
||||
custom_class = word.html["class"];
|
||||
delete word.html["class"];
|
||||
}
|
||||
|
||||
// Check if min(weight) > max(weight) otherwise use default
|
||||
if (word_array[0].weight > word_array[word_array.length - 1].weight) {
|
||||
// Linearly map the original weight to a discrete scale from 1 to 10
|
||||
weight = Math.round((word.weight - word_array[word_array.length - 1].weight) /
|
||||
(word_array[0].weight - word_array[word_array.length - 1].weight) * 9.0) + 1;
|
||||
}
|
||||
word_span = $('<span>').attr(word.html).addClass('w' + weight + " " + custom_class);
|
||||
|
||||
// Append link if word.url attribute was set
|
||||
if (word.link) {
|
||||
// If link is a string, then use it as the link href
|
||||
if (typeof word.link === "string") {
|
||||
word.link = {href: word.link};
|
||||
}
|
||||
|
||||
// Extend link html options with defaults
|
||||
if ( options.encodeURI ) {
|
||||
word.link = $.extend(word.link, { href: encodeURI(word.link.href).replace(/'/g, "%27") });
|
||||
}
|
||||
|
||||
inner_html = $('<a>').attr(word.link).text(word.text);
|
||||
} else {
|
||||
inner_html = word.text;
|
||||
}
|
||||
word_span.append(inner_html);
|
||||
|
||||
// Bind handlers to words
|
||||
if (!!word.handlers) {
|
||||
for (var prop in word.handlers) {
|
||||
if (word.handlers.hasOwnProperty(prop) && typeof word.handlers[prop] === 'function') {
|
||||
$(word_span).bind(prop, word.handlers[prop]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this.append(word_span);
|
||||
|
||||
var width = word_span.width(),
|
||||
height = word_span.height(),
|
||||
left = options.center.x - width / 2.0,
|
||||
top = options.center.y - height / 2.0;
|
||||
|
||||
// Save a reference to the style property, for better performance
|
||||
var word_style = word_span[0].style;
|
||||
word_style.position = "absolute";
|
||||
word_style.left = left + "px";
|
||||
word_style.top = top + "px";
|
||||
|
||||
while(hitTest(word_span[0], already_placed_words)) {
|
||||
// option shape is 'rectangular' so move the word in a rectangular spiral
|
||||
if (options.shape === "rectangular") {
|
||||
steps_in_direction++;
|
||||
if (steps_in_direction * step > (1 + Math.floor(quarter_turns / 2.0)) * step * ((quarter_turns % 4 % 2) === 0 ? 1 : aspect_ratio)) {
|
||||
steps_in_direction = 0.0;
|
||||
quarter_turns++;
|
||||
}
|
||||
switch(quarter_turns % 4) {
|
||||
case 1:
|
||||
left += step * aspect_ratio + Math.random() * 2.0;
|
||||
break;
|
||||
case 2:
|
||||
top -= step + Math.random() * 2.0;
|
||||
break;
|
||||
case 3:
|
||||
left -= step * aspect_ratio + Math.random() * 2.0;
|
||||
break;
|
||||
case 0:
|
||||
top += step + Math.random() * 2.0;
|
||||
break;
|
||||
}
|
||||
} else { // Default settings: elliptic spiral shape
|
||||
radius += step;
|
||||
angle += (index % 2 === 0 ? 1 : -1)*step;
|
||||
|
||||
left = options.center.x - (width / 2.0) + (radius*Math.cos(angle)) * aspect_ratio;
|
||||
top = options.center.y + radius*Math.sin(angle) - (height / 2.0);
|
||||
}
|
||||
word_style.left = left + "px";
|
||||
word_style.top = top + "px";
|
||||
}
|
||||
|
||||
// Don't render word if part of it would be outside the container
|
||||
if (options.removeOverflowing && (left < 0 || top < 0 || (left + width) > options.width || (top + height) > options.height)) {
|
||||
word_span.remove()
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
already_placed_words.push(word_span[0]);
|
||||
|
||||
// Invoke callback if existing
|
||||
if ($.isFunction(word.afterWordRender)) {
|
||||
word.afterWordRender.call(word_span);
|
||||
}
|
||||
};
|
||||
|
||||
var drawOneWordDelayed = function(index) {
|
||||
index = index || 0;
|
||||
if (!$this.is(':visible')) { // if not visible then do not attempt to draw
|
||||
setTimeout(function(){drawOneWordDelayed(index);},10);
|
||||
return;
|
||||
}
|
||||
if (index < word_array.length) {
|
||||
drawOneWord(index, word_array[index]);
|
||||
setTimeout(function(){drawOneWordDelayed(index + 1);}, 10);
|
||||
} else {
|
||||
if ($.isFunction(options.afterCloudRender)) {
|
||||
options.afterCloudRender.call($this);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Iterate drawOneWord on every word. The way the iteration is done depends on the drawing mode (delayedMode is true or false)
|
||||
if (options.delayedMode){
|
||||
drawOneWordDelayed();
|
||||
}
|
||||
else {
|
||||
$.each(word_array, drawOneWord);
|
||||
if ($.isFunction(options.afterCloudRender)) {
|
||||
options.afterCloudRender.call($this);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Delay execution so that the browser can render the page before the computatively intensive word cloud drawing
|
||||
setTimeout(function(){drawWordCloud();}, 10);
|
||||
return $this;
|
||||
};
|
||||
})(jQuery);
|
||||
4
template_static/lib/jquery-2.1.1.min.js
vendored
Normal file
4
template_static/lib/jquery-2.1.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
44
template_static/lib/jquery.flot.categories.min.js
vendored
Normal file
44
template_static/lib/jquery.flot.categories.min.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
/* Flot plugin for plotting textual data or categories.
|
||||
|
||||
Copyright (c) 2007-2013 IOLA and Ole Laursen.
|
||||
Licensed under the MIT license.
|
||||
|
||||
Consider a dataset like [["February", 34], ["March", 20], ...]. This plugin
|
||||
allows you to plot such a dataset directly.
|
||||
|
||||
To enable it, you must specify mode: "categories" on the axis with the textual
|
||||
labels, e.g.
|
||||
|
||||
$.plot("#placeholder", data, { xaxis: { mode: "categories" } });
|
||||
|
||||
By default, the labels are ordered as they are met in the data series. If you
|
||||
need a different ordering, you can specify "categories" on the axis options
|
||||
and list the categories there:
|
||||
|
||||
xaxis: {
|
||||
mode: "categories",
|
||||
categories: ["February", "March", "April"]
|
||||
}
|
||||
|
||||
If you need to customize the distances between the categories, you can specify
|
||||
"categories" as an object mapping labels to values
|
||||
|
||||
xaxis: {
|
||||
mode: "categories",
|
||||
categories: { "February": 1, "March": 3, "April": 4 }
|
||||
}
|
||||
|
||||
If you don't specify all categories, the remaining categories will be numbered
|
||||
from the max value plus 1 (with a spacing of 1 between each).
|
||||
|
||||
Internally, the plugin works by transforming the input data through an auto-
|
||||
generated mapping where the first category becomes 0, the second 1, etc.
|
||||
Hence, a point like ["February", 34] becomes [0, 34] internally in Flot (this
|
||||
is visible in hover and click events that return numbers rather than the
|
||||
category labels). The plugin also overrides the tick generator to spit out the
|
||||
categories as ticks instead of the values.
|
||||
|
||||
If you need to map a value back to its label, the mapping is always accessible
|
||||
as "categories" on the axis object, e.g. plot.getAxes().xaxis.categories.
|
||||
|
||||
*/(function(e){function n(e,t,n,r){var i=t.xaxis.options.mode=="categories",s=t.yaxis.options.mode=="categories";if(!i&&!s)return;var o=r.format;if(!o){var u=t;o=[],o.push({x:!0,number:!0,required:!0}),o.push({y:!0,number:!0,required:!0});if(u.bars.show||u.lines.show&&u.lines.fill){var a=!!(u.bars.show&&u.bars.zero||u.lines.show&&u.lines.zero);o.push({y:!0,number:!0,required:!1,defaultValue:0,autoscale:a}),u.bars.horizontal&&(delete o[o.length-1].y,o[o.length-1].x=!0)}r.format=o}for(var f=0;f<o.length;++f)o[f].x&&i&&(o[f].number=!1),o[f].y&&s&&(o[f].number=!1)}function r(e){var t=-1;for(var n in e)e[n]>t&&(t=e[n]);return t+1}function i(e){var t=[];for(var n in e.categories){var r=e.categories[n];r>=e.min&&r<=e.max&&t.push([r,n])}return t.sort(function(e,t){return e[0]-t[0]}),t}function s(t,n,r){if(t[n].options.mode!="categories")return;if(!t[n].categories){var s={},u=t[n].options.categories||{};if(e.isArray(u))for(var a=0;a<u.length;++a)s[u[a]]=a;else for(var f in u)s[f]=u[f];t[n].categories=s}t[n].options.ticks||(t[n].options.ticks=i),o(r,n,t[n].categories)}function o(e,t,n){var i=e.points,s=e.pointsize,o=e.format,u=t.charAt(0),a=r(n);for(var f=0;f<i.length;f+=s){if(i[f]==null)continue;for(var l=0;l<s;++l){var c=i[f+l];if(c==null||!o[l][u])continue;c in n||(n[c]=a,++a),i[f+l]=n[c]}}}function u(e,t,n){s(t,"xaxis",n),s(t,"yaxis",n)}function a(e){e.hooks.processRawData.push(n),e.hooks.processDatapoints.push(u)}var t={xaxis:{categories:null},yaxis:{categories:null}};e.plot.plugins.push({init:a,options:t,name:"categories",version:"1.0"})})(jQuery);
|
||||
8
template_static/lib/jquery.flot.min.js
vendored
Normal file
8
template_static/lib/jquery.flot.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
820
template_static/lib/jquery.flot.pie.js
Normal file
820
template_static/lib/jquery.flot.pie.js
Normal file
@@ -0,0 +1,820 @@
|
||||
/* Flot plugin for rendering pie charts.
|
||||
|
||||
Copyright (c) 2007-2014 IOLA and Ole Laursen.
|
||||
Licensed under the MIT license.
|
||||
|
||||
The plugin assumes that each series has a single data value, and that each
|
||||
value is a positive integer or zero. Negative numbers don't make sense for a
|
||||
pie chart, and have unpredictable results. The values do NOT need to be
|
||||
passed in as percentages; the plugin will calculate the total and per-slice
|
||||
percentages internally.
|
||||
|
||||
* Created by Brian Medendorp
|
||||
|
||||
* Updated with contributions from btburnett3, Anthony Aragues and Xavi Ivars
|
||||
|
||||
The plugin supports these options:
|
||||
|
||||
series: {
|
||||
pie: {
|
||||
show: true/false
|
||||
radius: 0-1 for percentage of fullsize, or a specified pixel length, or 'auto'
|
||||
innerRadius: 0-1 for percentage of fullsize or a specified pixel length, for creating a donut effect
|
||||
startAngle: 0-2 factor of PI used for starting angle (in radians) i.e 3/2 starts at the top, 0 and 2 have the same result
|
||||
tilt: 0-1 for percentage to tilt the pie, where 1 is no tilt, and 0 is completely flat (nothing will show)
|
||||
offset: {
|
||||
top: integer value to move the pie up or down
|
||||
left: integer value to move the pie left or right, or 'auto'
|
||||
},
|
||||
stroke: {
|
||||
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#FFF')
|
||||
width: integer pixel width of the stroke
|
||||
},
|
||||
label: {
|
||||
show: true/false, or 'auto'
|
||||
formatter: a user-defined function that modifies the text/style of the label text
|
||||
radius: 0-1 for percentage of fullsize, or a specified pixel length
|
||||
background: {
|
||||
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#000')
|
||||
opacity: 0-1
|
||||
},
|
||||
threshold: 0-1 for the percentage value at which to hide labels (if they're too small)
|
||||
},
|
||||
combine: {
|
||||
threshold: 0-1 for the percentage value at which to combine slices (if they're too small)
|
||||
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#CCC'), if null, the plugin will automatically use the color of the first slice to be combined
|
||||
label: any text value of what the combined slice should be labeled
|
||||
}
|
||||
highlight: {
|
||||
opacity: 0-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
More detail and specific examples can be found in the included HTML file.
|
||||
|
||||
*/
|
||||
|
||||
(function($) {
|
||||
|
||||
// Maximum redraw attempts when fitting labels within the plot
|
||||
|
||||
var REDRAW_ATTEMPTS = 10;
|
||||
|
||||
// Factor by which to shrink the pie when fitting labels within the plot
|
||||
|
||||
var REDRAW_SHRINK = 0.95;
|
||||
|
||||
function init(plot) {
|
||||
|
||||
var canvas = null,
|
||||
target = null,
|
||||
options = null,
|
||||
maxRadius = null,
|
||||
centerLeft = null,
|
||||
centerTop = null,
|
||||
processed = false,
|
||||
ctx = null;
|
||||
|
||||
// interactive variables
|
||||
|
||||
var highlights = [];
|
||||
|
||||
// add hook to determine if pie plugin in enabled, and then perform necessary operations
|
||||
|
||||
plot.hooks.processOptions.push(function(plot, options) {
|
||||
if (options.series.pie.show) {
|
||||
|
||||
options.grid.show = false;
|
||||
|
||||
// set labels.show
|
||||
|
||||
if (options.series.pie.label.show == "auto") {
|
||||
if (options.legend.show) {
|
||||
options.series.pie.label.show = false;
|
||||
} else {
|
||||
options.series.pie.label.show = true;
|
||||
}
|
||||
}
|
||||
|
||||
// set radius
|
||||
|
||||
if (options.series.pie.radius == "auto") {
|
||||
if (options.series.pie.label.show) {
|
||||
options.series.pie.radius = 3/4;
|
||||
} else {
|
||||
options.series.pie.radius = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ensure sane tilt
|
||||
|
||||
if (options.series.pie.tilt > 1) {
|
||||
options.series.pie.tilt = 1;
|
||||
} else if (options.series.pie.tilt < 0) {
|
||||
options.series.pie.tilt = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
plot.hooks.bindEvents.push(function(plot, eventHolder) {
|
||||
var options = plot.getOptions();
|
||||
if (options.series.pie.show) {
|
||||
if (options.grid.hoverable) {
|
||||
eventHolder.unbind("mousemove").mousemove(onMouseMove);
|
||||
}
|
||||
if (options.grid.clickable) {
|
||||
eventHolder.unbind("click").click(onClick);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
plot.hooks.processDatapoints.push(function(plot, series, data, datapoints) {
|
||||
var options = plot.getOptions();
|
||||
if (options.series.pie.show) {
|
||||
processDatapoints(plot, series, data, datapoints);
|
||||
}
|
||||
});
|
||||
|
||||
plot.hooks.drawOverlay.push(function(plot, octx) {
|
||||
var options = plot.getOptions();
|
||||
if (options.series.pie.show) {
|
||||
drawOverlay(plot, octx);
|
||||
}
|
||||
});
|
||||
|
||||
plot.hooks.draw.push(function(plot, newCtx) {
|
||||
var options = plot.getOptions();
|
||||
if (options.series.pie.show) {
|
||||
draw(plot, newCtx);
|
||||
}
|
||||
});
|
||||
|
||||
function processDatapoints(plot, series, datapoints) {
|
||||
if (!processed) {
|
||||
processed = true;
|
||||
canvas = plot.getCanvas();
|
||||
target = $(canvas).parent();
|
||||
options = plot.getOptions();
|
||||
plot.setData(combine(plot.getData()));
|
||||
}
|
||||
}
|
||||
|
||||
function combine(data) {
|
||||
|
||||
var total = 0,
|
||||
combined = 0,
|
||||
numCombined = 0,
|
||||
color = options.series.pie.combine.color,
|
||||
newdata = [];
|
||||
|
||||
// Fix up the raw data from Flot, ensuring the data is numeric
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
|
||||
var value = data[i].data;
|
||||
|
||||
// If the data is an array, we'll assume that it's a standard
|
||||
// Flot x-y pair, and are concerned only with the second value.
|
||||
|
||||
// Note how we use the original array, rather than creating a
|
||||
// new one; this is more efficient and preserves any extra data
|
||||
// that the user may have stored in higher indexes.
|
||||
|
||||
if ($.isArray(value) && value.length == 1) {
|
||||
value = value[0];
|
||||
}
|
||||
|
||||
if ($.isArray(value)) {
|
||||
// Equivalent to $.isNumeric() but compatible with jQuery < 1.7
|
||||
if (!isNaN(parseFloat(value[1])) && isFinite(value[1])) {
|
||||
value[1] = +value[1];
|
||||
} else {
|
||||
value[1] = 0;
|
||||
}
|
||||
} else if (!isNaN(parseFloat(value)) && isFinite(value)) {
|
||||
value = [1, +value];
|
||||
} else {
|
||||
value = [1, 0];
|
||||
}
|
||||
|
||||
data[i].data = [value];
|
||||
}
|
||||
|
||||
// Sum up all the slices, so we can calculate percentages for each
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
total += data[i].data[0][1];
|
||||
}
|
||||
|
||||
// Count the number of slices with percentages below the combine
|
||||
// threshold; if it turns out to be just one, we won't combine.
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
var value = data[i].data[0][1];
|
||||
if (value / total <= options.series.pie.combine.threshold) {
|
||||
combined += value;
|
||||
numCombined++;
|
||||
if (!color) {
|
||||
color = data[i].color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
var value = data[i].data[0][1];
|
||||
if (numCombined < 2 || value / total > options.series.pie.combine.threshold) {
|
||||
newdata.push(
|
||||
$.extend(data[i], { /* extend to allow keeping all other original data values
|
||||
and using them e.g. in labelFormatter. */
|
||||
data: [[1, value]],
|
||||
color: data[i].color,
|
||||
label: data[i].label,
|
||||
angle: value * Math.PI * 2 / total,
|
||||
percent: value / (total / 100)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (numCombined > 1) {
|
||||
newdata.push({
|
||||
data: [[1, combined]],
|
||||
color: color,
|
||||
label: options.series.pie.combine.label,
|
||||
angle: combined * Math.PI * 2 / total,
|
||||
percent: combined / (total / 100)
|
||||
});
|
||||
}
|
||||
|
||||
return newdata;
|
||||
}
|
||||
|
||||
function draw(plot, newCtx) {
|
||||
|
||||
if (!target) {
|
||||
return; // if no series were passed
|
||||
}
|
||||
|
||||
var canvasWidth = plot.getPlaceholder().width(),
|
||||
canvasHeight = plot.getPlaceholder().height(),
|
||||
legendWidth = target.children().filter(".legend").children().width() || 0;
|
||||
|
||||
ctx = newCtx;
|
||||
|
||||
// WARNING: HACK! REWRITE THIS CODE AS SOON AS POSSIBLE!
|
||||
|
||||
// When combining smaller slices into an 'other' slice, we need to
|
||||
// add a new series. Since Flot gives plugins no way to modify the
|
||||
// list of series, the pie plugin uses a hack where the first call
|
||||
// to processDatapoints results in a call to setData with the new
|
||||
// list of series, then subsequent processDatapoints do nothing.
|
||||
|
||||
// The plugin-global 'processed' flag is used to control this hack;
|
||||
// it starts out false, and is set to true after the first call to
|
||||
// processDatapoints.
|
||||
|
||||
// Unfortunately this turns future setData calls into no-ops; they
|
||||
// call processDatapoints, the flag is true, and nothing happens.
|
||||
|
||||
// To fix this we'll set the flag back to false here in draw, when
|
||||
// all series have been processed, so the next sequence of calls to
|
||||
// processDatapoints once again starts out with a slice-combine.
|
||||
// This is really a hack; in 0.9 we need to give plugins a proper
|
||||
// way to modify series before any processing begins.
|
||||
|
||||
processed = false;
|
||||
|
||||
// calculate maximum radius and center point
|
||||
|
||||
maxRadius = Math.min(canvasWidth, canvasHeight / options.series.pie.tilt) / 2;
|
||||
centerTop = canvasHeight / 2 + options.series.pie.offset.top;
|
||||
centerLeft = canvasWidth / 2;
|
||||
|
||||
if (options.series.pie.offset.left == "auto") {
|
||||
if (options.legend.position.match("w")) {
|
||||
centerLeft += legendWidth / 2;
|
||||
} else {
|
||||
centerLeft -= legendWidth / 2;
|
||||
}
|
||||
if (centerLeft < maxRadius) {
|
||||
centerLeft = maxRadius;
|
||||
} else if (centerLeft > canvasWidth - maxRadius) {
|
||||
centerLeft = canvasWidth - maxRadius;
|
||||
}
|
||||
} else {
|
||||
centerLeft += options.series.pie.offset.left;
|
||||
}
|
||||
|
||||
var slices = plot.getData(),
|
||||
attempts = 0;
|
||||
|
||||
// Keep shrinking the pie's radius until drawPie returns true,
|
||||
// indicating that all the labels fit, or we try too many times.
|
||||
|
||||
do {
|
||||
if (attempts > 0) {
|
||||
maxRadius *= REDRAW_SHRINK;
|
||||
}
|
||||
attempts += 1;
|
||||
clear();
|
||||
if (options.series.pie.tilt <= 0.8) {
|
||||
drawShadow();
|
||||
}
|
||||
} while (!drawPie() && attempts < REDRAW_ATTEMPTS)
|
||||
|
||||
if (attempts >= REDRAW_ATTEMPTS) {
|
||||
clear();
|
||||
target.prepend("<div class='error'>Could not draw pie with labels contained inside canvas</div>");
|
||||
}
|
||||
|
||||
if (plot.setSeries && plot.insertLegend) {
|
||||
plot.setSeries(slices);
|
||||
plot.insertLegend();
|
||||
}
|
||||
|
||||
// we're actually done at this point, just defining internal functions at this point
|
||||
|
||||
function clear() {
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
target.children().filter(".pieLabel, .pieLabelBackground").remove();
|
||||
}
|
||||
|
||||
function drawShadow() {
|
||||
|
||||
var shadowLeft = options.series.pie.shadow.left;
|
||||
var shadowTop = options.series.pie.shadow.top;
|
||||
var edge = 10;
|
||||
var alpha = options.series.pie.shadow.alpha;
|
||||
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
|
||||
|
||||
if (radius >= canvasWidth / 2 - shadowLeft || radius * options.series.pie.tilt >= canvasHeight / 2 - shadowTop || radius <= edge) {
|
||||
return; // shadow would be outside canvas, so don't draw it
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(shadowLeft,shadowTop);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
// center and rotate to starting position
|
||||
|
||||
ctx.translate(centerLeft,centerTop);
|
||||
ctx.scale(1, options.series.pie.tilt);
|
||||
|
||||
//radius -= edge;
|
||||
|
||||
for (var i = 1; i <= edge; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, radius, 0, Math.PI * 2, false);
|
||||
ctx.fill();
|
||||
radius -= i;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPie() {
|
||||
|
||||
var startAngle = Math.PI * options.series.pie.startAngle;
|
||||
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
|
||||
|
||||
// center and rotate to starting position
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(centerLeft,centerTop);
|
||||
ctx.scale(1, options.series.pie.tilt);
|
||||
//ctx.rotate(startAngle); // start at top; -- This doesn't work properly in Opera
|
||||
|
||||
// draw slices
|
||||
|
||||
ctx.save();
|
||||
var currentAngle = startAngle;
|
||||
for (var i = 0; i < slices.length; ++i) {
|
||||
slices[i].startAngle = currentAngle;
|
||||
drawSlice(slices[i].angle, slices[i].color, true);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// draw slice outlines
|
||||
|
||||
if (options.series.pie.stroke.width > 0) {
|
||||
ctx.save();
|
||||
ctx.lineWidth = options.series.pie.stroke.width;
|
||||
currentAngle = startAngle;
|
||||
for (var i = 0; i < slices.length; ++i) {
|
||||
drawSlice(slices[i].angle, options.series.pie.stroke.color, false);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// draw donut hole
|
||||
|
||||
drawDonutHole(ctx);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Draw the labels, returning true if they fit within the plot
|
||||
|
||||
if (options.series.pie.label.show) {
|
||||
return drawLabels();
|
||||
} else return true;
|
||||
|
||||
function drawSlice(angle, color, fill) {
|
||||
|
||||
if (angle <= 0 || isNaN(angle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fill) {
|
||||
ctx.fillStyle = color;
|
||||
} else {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineJoin = "round";
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
if (Math.abs(angle - Math.PI * 2) > 0.000000001) {
|
||||
ctx.moveTo(0, 0); // Center of the pie
|
||||
}
|
||||
|
||||
//ctx.arc(0, 0, radius, 0, angle, false); // This doesn't work properly in Opera
|
||||
ctx.arc(0, 0, radius,currentAngle, currentAngle + angle / 2, false);
|
||||
ctx.arc(0, 0, radius,currentAngle + angle / 2, currentAngle + angle, false);
|
||||
ctx.closePath();
|
||||
//ctx.rotate(angle); // This doesn't work properly in Opera
|
||||
currentAngle += angle;
|
||||
|
||||
if (fill) {
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawLabels() {
|
||||
|
||||
var currentAngle = startAngle;
|
||||
var radius = options.series.pie.label.radius > 1 ? options.series.pie.label.radius : maxRadius * options.series.pie.label.radius;
|
||||
|
||||
for (var i = 0; i < slices.length; ++i) {
|
||||
if (slices[i].percent >= options.series.pie.label.threshold * 100) {
|
||||
if (!drawLabel(slices[i], currentAngle, i)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
currentAngle += slices[i].angle;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
function drawLabel(slice, startAngle, index) {
|
||||
|
||||
if (slice.data[0][1] == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// format label text
|
||||
|
||||
var lf = options.legend.labelFormatter, text, plf = options.series.pie.label.formatter;
|
||||
|
||||
if (lf) {
|
||||
text = lf(slice.label, slice);
|
||||
} else {
|
||||
text = slice.label;
|
||||
}
|
||||
|
||||
if (plf) {
|
||||
text = plf(text, slice);
|
||||
}
|
||||
|
||||
var halfAngle = ((startAngle + slice.angle) + startAngle) / 2;
|
||||
var x = centerLeft + Math.round(Math.cos(halfAngle) * radius);
|
||||
var y = centerTop + Math.round(Math.sin(halfAngle) * radius) * options.series.pie.tilt;
|
||||
|
||||
var html = "<span class='pieLabel' id='pieLabel" + index + "' style='position:absolute;top:" + y + "px;left:" + x + "px;'>" + text + "</span>";
|
||||
target.append(html);
|
||||
|
||||
var label = target.children("#pieLabel" + index);
|
||||
var labelTop = (y - label.height() / 2);
|
||||
var labelLeft = (x - label.width() / 2);
|
||||
|
||||
label.css("top", labelTop);
|
||||
label.css("left", labelLeft);
|
||||
|
||||
// check to make sure that the label is not outside the canvas
|
||||
|
||||
if (0 - labelTop > 0 || 0 - labelLeft > 0 || canvasHeight - (labelTop + label.height()) < 0 || canvasWidth - (labelLeft + label.width()) < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.series.pie.label.background.opacity != 0) {
|
||||
|
||||
// put in the transparent background separately to avoid blended labels and label boxes
|
||||
|
||||
var c = options.series.pie.label.background.color;
|
||||
|
||||
if (c == null) {
|
||||
c = slice.color;
|
||||
}
|
||||
|
||||
var pos = "top:" + labelTop + "px;left:" + labelLeft + "px;";
|
||||
$("<div class='pieLabelBackground' style='position:absolute;width:" + label.width() + "px;height:" + label.height() + "px;" + pos + "background-color:" + c + ";'></div>")
|
||||
.css("opacity", options.series.pie.label.background.opacity)
|
||||
.insertBefore(label);
|
||||
}
|
||||
|
||||
return true;
|
||||
} // end individual label function
|
||||
} // end drawLabels function
|
||||
} // end drawPie function
|
||||
} // end draw function
|
||||
|
||||
// Placed here because it needs to be accessed from multiple locations
|
||||
|
||||
function drawDonutHole(layer) {
|
||||
if (options.series.pie.innerRadius > 0) {
|
||||
|
||||
// subtract the center
|
||||
|
||||
layer.save();
|
||||
var innerRadius = options.series.pie.innerRadius > 1 ? options.series.pie.innerRadius : maxRadius * options.series.pie.innerRadius;
|
||||
layer.globalCompositeOperation = "destination-out"; // this does not work with excanvas, but it will fall back to using the stroke color
|
||||
layer.beginPath();
|
||||
layer.fillStyle = options.series.pie.stroke.color;
|
||||
layer.arc(0, 0, innerRadius, 0, Math.PI * 2, false);
|
||||
layer.fill();
|
||||
layer.closePath();
|
||||
layer.restore();
|
||||
|
||||
// add inner stroke
|
||||
|
||||
layer.save();
|
||||
layer.beginPath();
|
||||
layer.strokeStyle = options.series.pie.stroke.color;
|
||||
layer.arc(0, 0, innerRadius, 0, Math.PI * 2, false);
|
||||
layer.stroke();
|
||||
layer.closePath();
|
||||
layer.restore();
|
||||
|
||||
// TODO: add extra shadow inside hole (with a mask) if the pie is tilted.
|
||||
}
|
||||
}
|
||||
|
||||
//-- Additional Interactive related functions --
|
||||
|
||||
function isPointInPoly(poly, pt) {
|
||||
for(var c = false, i = -1, l = poly.length, j = l - 1; ++i < l; j = i)
|
||||
((poly[i][1] <= pt[1] && pt[1] < poly[j][1]) || (poly[j][1] <= pt[1] && pt[1]< poly[i][1]))
|
||||
&& (pt[0] < (poly[j][0] - poly[i][0]) * (pt[1] - poly[i][1]) / (poly[j][1] - poly[i][1]) + poly[i][0])
|
||||
&& (c = !c);
|
||||
return c;
|
||||
}
|
||||
|
||||
function findNearbySlice(mouseX, mouseY) {
|
||||
|
||||
var slices = plot.getData(),
|
||||
options = plot.getOptions(),
|
||||
radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius,
|
||||
x, y;
|
||||
|
||||
for (var i = 0; i < slices.length; ++i) {
|
||||
|
||||
var s = slices[i];
|
||||
|
||||
if (s.pie.show) {
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0); // Center of the pie
|
||||
//ctx.scale(1, options.series.pie.tilt); // this actually seems to break everything when here.
|
||||
ctx.arc(0, 0, radius, s.startAngle, s.startAngle + s.angle / 2, false);
|
||||
ctx.arc(0, 0, radius, s.startAngle + s.angle / 2, s.startAngle + s.angle, false);
|
||||
ctx.closePath();
|
||||
x = mouseX - centerLeft;
|
||||
y = mouseY - centerTop;
|
||||
|
||||
if (ctx.isPointInPath) {
|
||||
if (ctx.isPointInPath(mouseX - centerLeft, mouseY - centerTop)) {
|
||||
ctx.restore();
|
||||
return {
|
||||
datapoint: [s.percent, s.data],
|
||||
dataIndex: 0,
|
||||
series: s,
|
||||
seriesIndex: i
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
// excanvas for IE doesn;t support isPointInPath, this is a workaround.
|
||||
|
||||
var p1X = radius * Math.cos(s.startAngle),
|
||||
p1Y = radius * Math.sin(s.startAngle),
|
||||
p2X = radius * Math.cos(s.startAngle + s.angle / 4),
|
||||
p2Y = radius * Math.sin(s.startAngle + s.angle / 4),
|
||||
p3X = radius * Math.cos(s.startAngle + s.angle / 2),
|
||||
p3Y = radius * Math.sin(s.startAngle + s.angle / 2),
|
||||
p4X = radius * Math.cos(s.startAngle + s.angle / 1.5),
|
||||
p4Y = radius * Math.sin(s.startAngle + s.angle / 1.5),
|
||||
p5X = radius * Math.cos(s.startAngle + s.angle),
|
||||
p5Y = radius * Math.sin(s.startAngle + s.angle),
|
||||
arrPoly = [[0, 0], [p1X, p1Y], [p2X, p2Y], [p3X, p3Y], [p4X, p4Y], [p5X, p5Y]],
|
||||
arrPoint = [x, y];
|
||||
|
||||
// TODO: perhaps do some mathmatical trickery here with the Y-coordinate to compensate for pie tilt?
|
||||
|
||||
if (isPointInPoly(arrPoly, arrPoint)) {
|
||||
ctx.restore();
|
||||
return {
|
||||
datapoint: [s.percent, s.data],
|
||||
dataIndex: 0,
|
||||
series: s,
|
||||
seriesIndex: i
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
triggerClickHoverEvent("plothover", e);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
triggerClickHoverEvent("plotclick", e);
|
||||
}
|
||||
|
||||
// trigger click or hover event (they send the same parameters so we share their code)
|
||||
|
||||
function triggerClickHoverEvent(eventname, e) {
|
||||
|
||||
var offset = plot.offset();
|
||||
var canvasX = parseInt(e.pageX - offset.left);
|
||||
var canvasY = parseInt(e.pageY - offset.top);
|
||||
var item = findNearbySlice(canvasX, canvasY);
|
||||
|
||||
if (options.grid.autoHighlight) {
|
||||
|
||||
// clear auto-highlights
|
||||
|
||||
for (var i = 0; i < highlights.length; ++i) {
|
||||
var h = highlights[i];
|
||||
if (h.auto == eventname && !(item && h.series == item.series)) {
|
||||
unhighlight(h.series);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// highlight the slice
|
||||
|
||||
if (item) {
|
||||
highlight(item.series, eventname);
|
||||
}
|
||||
|
||||
// trigger any hover bind events
|
||||
|
||||
var pos = { pageX: e.pageX, pageY: e.pageY };
|
||||
target.trigger(eventname, [pos, item]);
|
||||
}
|
||||
|
||||
function highlight(s, auto) {
|
||||
//if (typeof s == "number") {
|
||||
// s = series[s];
|
||||
//}
|
||||
|
||||
var i = indexOfHighlight(s);
|
||||
|
||||
if (i == -1) {
|
||||
highlights.push({ series: s, auto: auto });
|
||||
plot.triggerRedrawOverlay();
|
||||
} else if (!auto) {
|
||||
highlights[i].auto = false;
|
||||
}
|
||||
}
|
||||
|
||||
function unhighlight(s) {
|
||||
if (s == null) {
|
||||
highlights = [];
|
||||
plot.triggerRedrawOverlay();
|
||||
}
|
||||
|
||||
//if (typeof s == "number") {
|
||||
// s = series[s];
|
||||
//}
|
||||
|
||||
var i = indexOfHighlight(s);
|
||||
|
||||
if (i != -1) {
|
||||
highlights.splice(i, 1);
|
||||
plot.triggerRedrawOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function indexOfHighlight(s) {
|
||||
for (var i = 0; i < highlights.length; ++i) {
|
||||
var h = highlights[i];
|
||||
if (h.series == s)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function drawOverlay(plot, octx) {
|
||||
|
||||
var options = plot.getOptions();
|
||||
|
||||
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
|
||||
|
||||
octx.save();
|
||||
octx.translate(centerLeft, centerTop);
|
||||
octx.scale(1, options.series.pie.tilt);
|
||||
|
||||
for (var i = 0; i < highlights.length; ++i) {
|
||||
drawHighlight(highlights[i].series);
|
||||
}
|
||||
|
||||
drawDonutHole(octx);
|
||||
|
||||
octx.restore();
|
||||
|
||||
function drawHighlight(series) {
|
||||
|
||||
if (series.angle <= 0 || isNaN(series.angle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
//octx.fillStyle = parseColor(options.series.pie.highlight.color).scale(null, null, null, options.series.pie.highlight.opacity).toString();
|
||||
octx.fillStyle = "rgba(255, 255, 255, " + options.series.pie.highlight.opacity + ")"; // this is temporary until we have access to parseColor
|
||||
octx.beginPath();
|
||||
if (Math.abs(series.angle - Math.PI * 2) > 0.000000001) {
|
||||
octx.moveTo(0, 0); // Center of the pie
|
||||
}
|
||||
octx.arc(0, 0, radius, series.startAngle, series.startAngle + series.angle / 2, false);
|
||||
octx.arc(0, 0, radius, series.startAngle + series.angle / 2, series.startAngle + series.angle, false);
|
||||
octx.closePath();
|
||||
octx.fill();
|
||||
}
|
||||
}
|
||||
} // end init (plugin body)
|
||||
|
||||
// define pie specific options and their default values
|
||||
|
||||
var options = {
|
||||
series: {
|
||||
pie: {
|
||||
show: false,
|
||||
radius: "auto", // actual radius of the visible pie (based on full calculated radius if <=1, or hard pixel value)
|
||||
innerRadius: 0, /* for donut */
|
||||
startAngle: 3/2,
|
||||
tilt: 1,
|
||||
shadow: {
|
||||
left: 5, // shadow left offset
|
||||
top: 15, // shadow top offset
|
||||
alpha: 0.02 // shadow alpha
|
||||
},
|
||||
offset: {
|
||||
top: 0,
|
||||
left: "auto"
|
||||
},
|
||||
stroke: {
|
||||
color: "#fff",
|
||||
width: 1
|
||||
},
|
||||
label: {
|
||||
show: "auto",
|
||||
formatter: function(label, slice) {
|
||||
return "<div style='font-size:x-small;text-align:center;padding:2px;color:" + slice.color + ";'>" + label + "<br/>" + Math.round(slice.percent) + "%</div>";
|
||||
}, // formatter function
|
||||
radius: 1, // radius at which to place the labels (based on full calculated radius if <=1, or hard pixel value)
|
||||
background: {
|
||||
color: null,
|
||||
opacity: 0
|
||||
},
|
||||
threshold: 0 // percentage at which to hide the label (i.e. the slice is too narrow)
|
||||
},
|
||||
combine: {
|
||||
threshold: -1, // percentage at which to combine little slices into one larger slice
|
||||
color: null, // color to give the new slice (auto-generated if null)
|
||||
label: "Other" // label to give the new slice
|
||||
},
|
||||
highlight: {
|
||||
//color: "#fff", // will add this functionality once parseColor is available
|
||||
opacity: 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$.plot.plugins.push({
|
||||
init: init,
|
||||
options: options,
|
||||
name: "pie",
|
||||
version: "1.1"
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
14
template_static/lib/log.js
Normal file
14
template_static/lib/log.js
Normal file
@@ -0,0 +1,14 @@
|
||||
(function($, block) {
|
||||
block.fn.log = function(config) {
|
||||
this.$element.addClass('block log').append('<ul>');
|
||||
|
||||
this.actions(function(e, message){
|
||||
$ul = $('ul:first-child', this);
|
||||
$ul.append('<li>');
|
||||
$ul.find("> li:last-child").text(message.text);
|
||||
$(this).scrollTop(1000000);
|
||||
});
|
||||
|
||||
return this.$element;
|
||||
};
|
||||
})(jQuery, block);
|
||||
128
template_static/lib/tweets.js
Normal file
128
template_static/lib/tweets.js
Normal file
@@ -0,0 +1,128 @@
|
||||
(function($, block) {
|
||||
|
||||
// Entity formatters for use by tweet list
|
||||
var entity_formatters = {
|
||||
'urls': function(e) {
|
||||
return '<a href="' + e.url + '">' + e.display_url + '</a>';
|
||||
},
|
||||
|
||||
'user_mentions': function(e) {
|
||||
return '<a href="https://twitter.com/'+e.screen_name+'">@'+e.screen_name+'</a>';
|
||||
},
|
||||
|
||||
'hashtags': function(e) {
|
||||
return '<a href="https://twitter.com/hashtag/'+e.text+'?src=hash">#' +e.text+'</a>';
|
||||
},
|
||||
|
||||
'default': function(e) {
|
||||
return '{ENTITY}';
|
||||
}
|
||||
};
|
||||
|
||||
// processes entities for the given message and entity object
|
||||
var process_entities = function(message, entities) {
|
||||
// short-circuit failure mode
|
||||
if(typeof entities === 'undefined') {
|
||||
return message;
|
||||
}
|
||||
|
||||
// build list of entities sorted on starting index
|
||||
var es = [];
|
||||
|
||||
$.each(entities, function(t, ts) {
|
||||
$.each(ts, function(_, e) {
|
||||
e['type'] = t;
|
||||
es.push(e);
|
||||
});
|
||||
});
|
||||
|
||||
es.sort(function(a,b) {
|
||||
return a['indices'][0] - b['indices'][0];
|
||||
});
|
||||
|
||||
// process entities one-by-one in order of appearance
|
||||
var marker = 0;
|
||||
var result = "";
|
||||
for(var i in es) {
|
||||
var e = es[i];
|
||||
var start = e['indices'][0];
|
||||
var stop = e['indices'][1];
|
||||
|
||||
//copy string content
|
||||
result += message.substring(marker, start);
|
||||
|
||||
//process entity (through formatter or no-op function)
|
||||
var formatter = entity_formatters[e.type]
|
||||
|| function(e) { return message.substring(start,stop) };
|
||||
result += formatter(e);
|
||||
|
||||
// update marker location
|
||||
marker = stop;
|
||||
}
|
||||
|
||||
// append tail of message
|
||||
result += message.substring(marker, message.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
block.fn.tweets = function(config) {
|
||||
var options = $.extend({
|
||||
memory: 20
|
||||
}, config);
|
||||
|
||||
// create the necessary HTML in the block container
|
||||
this.$element.append('<ol class="tweet-list stream-items"></ol>');
|
||||
|
||||
// store list for later
|
||||
var $list = this.$element.find('ol');
|
||||
|
||||
|
||||
// register default handler for handling tweet data
|
||||
this.actions(function(e, tweet){
|
||||
var $item = $('<li class="stream-item"></li>');
|
||||
|
||||
var $tweet = $('<div class="tweet"></div>');
|
||||
var $content = $('<div class="content"></div>');
|
||||
var $header = $('<div class="stream-item-header"></div>');
|
||||
|
||||
// Build a tag image and header:
|
||||
var $account = $('<a class="account-group"></a>');
|
||||
$account.attr("href", "http://twitter.com/" + tweet.user.screen_name);
|
||||
|
||||
var $avatar = $("<img>").addClass("avatar");
|
||||
$avatar.attr("src", tweet.user.profile_image_url);
|
||||
$account.append($avatar);
|
||||
$account.append($('<strong class="fullname">' + tweet.user.name + '</strong>'));
|
||||
$account.append($('<span> </span>'));
|
||||
$account.append($('<span class="username"><s>@</s><b>' + tweet.user.screen_name + '</b></span>'));
|
||||
$header.append($account);
|
||||
|
||||
// Build timestamp:
|
||||
var $time = $('<small class="time"></small>');
|
||||
$time.append($('<span>' + tweet.created_at + '</span>'));
|
||||
|
||||
$header.append($time);
|
||||
$content.append($header);
|
||||
|
||||
// Build contents:
|
||||
var text = process_entities(tweet.text, tweet.entities);
|
||||
var $text = $('<p class="tweet-text">' + text + '</p>');
|
||||
$content.append($text);
|
||||
|
||||
// Build outer structure of containing divs:
|
||||
$tweet.append($content);
|
||||
$item.append($tweet);
|
||||
|
||||
// place new tweet in front of list
|
||||
$list.prepend($item);
|
||||
|
||||
// remove stale tweets
|
||||
if ($list.children().length > options.memory) {
|
||||
$list.children().last().remove();
|
||||
}
|
||||
});
|
||||
|
||||
return this.$element;
|
||||
};
|
||||
})(jQuery, block);
|
||||
88
template_static/lib/wordcloud.js
Normal file
88
template_static/lib/wordcloud.js
Normal file
@@ -0,0 +1,88 @@
|
||||
(function($, block) {
|
||||
// a simple wordcloud example
|
||||
block.fn.wordcloud = function(config) {
|
||||
var options = $.extend({
|
||||
filter_function : function(cat,val,max) { return true; },
|
||||
weight_function : function(cat,val,max) { return val; },
|
||||
options : {}
|
||||
}, config);
|
||||
|
||||
var $container = $(this.$element);
|
||||
|
||||
// dict containing the labels and values
|
||||
var worddata_dict = {};
|
||||
var dirty = false;
|
||||
|
||||
var addword = function(label, value) {
|
||||
if (worddata_dict.hasOwnProperty(label)) {
|
||||
worddata_dict[label] += value;
|
||||
} else {
|
||||
worddata_dict[label] = value;
|
||||
}
|
||||
flag_dirty();
|
||||
}
|
||||
|
||||
var setword = function(label, value) {
|
||||
worddata_dict[label] = value;
|
||||
flag_dirty();
|
||||
}
|
||||
|
||||
var flag_dirty = function() {
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
var redraw = function() {
|
||||
if(!dirty) {
|
||||
window.setTimeout(redraw, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = [];
|
||||
var max = 0;
|
||||
|
||||
for (var k in worddata_dict) {
|
||||
if (worddata_dict.hasOwnProperty(k)) {
|
||||
max = Math.max(max, worddata_dict[k]);
|
||||
}
|
||||
}
|
||||
|
||||
for (var k in worddata_dict) {
|
||||
if (worddata_dict.hasOwnProperty(k)) {
|
||||
var val = worddata_dict[k];
|
||||
if (options.filter_function(k,val,max)) {
|
||||
result.push({
|
||||
text: k,
|
||||
weight: options.weight_function(k,val,max)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
$($container).empty().jQCloud(result,$.extend(options.options,{delayedMode: false}));
|
||||
|
||||
dirty = false;
|
||||
window.setTimeout(redraw, 500);
|
||||
}
|
||||
|
||||
var reset = function() {
|
||||
worddata_dict = {};
|
||||
}
|
||||
|
||||
this.actions({
|
||||
'set': function(e, message) {
|
||||
setword(message.value[0], message.value[1]);
|
||||
},
|
||||
'add': function(e, message) {
|
||||
addword(message.value[0], message.value[1]);
|
||||
},
|
||||
'reset': function(e, message) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
|
||||
// start redraw loop
|
||||
window.setTimeout(redraw, 500);
|
||||
|
||||
// return element to allow further work
|
||||
return this.$element;
|
||||
}
|
||||
})(jQuery, block);
|
||||
374
template_static/style/grid.css
Normal file
374
template_static/style/grid.css
Normal file
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
Variable Grid System.
|
||||
Learn more ~ http://www.spry-soft.com/grids/
|
||||
Based on 960 Grid System - http://960.gs/
|
||||
|
||||
Licensed under GPL and MIT.
|
||||
*/
|
||||
|
||||
/*
|
||||
Forces backgrounds to span full width,
|
||||
even if there is horizontal scrolling.
|
||||
Increase this if your layout is wider.
|
||||
|
||||
Note: IE6 works fine without this fix.
|
||||
*/
|
||||
|
||||
body {
|
||||
min-width: 960px;
|
||||
}
|
||||
|
||||
/* Containers
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
.container_12 {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 960px;
|
||||
}
|
||||
|
||||
/* Grid >> Global
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.grid_1,
|
||||
.grid_2,
|
||||
.grid_3,
|
||||
.grid_4,
|
||||
.grid_5,
|
||||
.grid_6,
|
||||
.grid_7,
|
||||
.grid_8,
|
||||
.grid_9,
|
||||
.grid_10,
|
||||
.grid_11,
|
||||
.grid_12 {
|
||||
display:inline;
|
||||
float: left;
|
||||
position: relative;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.push_1, .pull_1,
|
||||
.push_2, .pull_2,
|
||||
.push_3, .pull_3,
|
||||
.push_4, .pull_4,
|
||||
.push_5, .pull_5,
|
||||
.push_6, .pull_6,
|
||||
.push_7, .pull_7,
|
||||
.push_8, .pull_8,
|
||||
.push_9, .pull_9,
|
||||
.push_10, .pull_10,
|
||||
.push_11, .pull_11,
|
||||
.push_12, .pull_12 {
|
||||
position:relative;
|
||||
}
|
||||
|
||||
|
||||
/* Grid >> Children (Alpha ~ First, Omega ~ Last)
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
.alpha {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.omega {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Grid >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .grid_1 {
|
||||
width:60px;
|
||||
}
|
||||
|
||||
.container_12 .grid_2 {
|
||||
width:140px;
|
||||
}
|
||||
|
||||
.container_12 .grid_3 {
|
||||
width:220px;
|
||||
}
|
||||
|
||||
.container_12 .grid_4 {
|
||||
width:300px;
|
||||
}
|
||||
|
||||
.container_12 .grid_5 {
|
||||
width:380px;
|
||||
}
|
||||
|
||||
.container_12 .grid_6 {
|
||||
width:460px;
|
||||
}
|
||||
|
||||
.container_12 .grid_7 {
|
||||
width:540px;
|
||||
}
|
||||
|
||||
.container_12 .grid_8 {
|
||||
width:620px;
|
||||
}
|
||||
|
||||
.container_12 .grid_9 {
|
||||
width:700px;
|
||||
}
|
||||
|
||||
.container_12 .grid_10 {
|
||||
width:780px;
|
||||
}
|
||||
|
||||
.container_12 .grid_11 {
|
||||
width:860px;
|
||||
}
|
||||
|
||||
.container_12 .grid_12 {
|
||||
width:940px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Prefix Extra Space >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .prefix_1 {
|
||||
padding-left:80px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_2 {
|
||||
padding-left:160px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_3 {
|
||||
padding-left:240px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_4 {
|
||||
padding-left:320px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_5 {
|
||||
padding-left:400px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_6 {
|
||||
padding-left:480px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_7 {
|
||||
padding-left:560px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_8 {
|
||||
padding-left:640px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_9 {
|
||||
padding-left:720px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_10 {
|
||||
padding-left:800px;
|
||||
}
|
||||
|
||||
.container_12 .prefix_11 {
|
||||
padding-left:880px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Suffix Extra Space >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .suffix_1 {
|
||||
padding-right:80px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_2 {
|
||||
padding-right:160px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_3 {
|
||||
padding-right:240px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_4 {
|
||||
padding-right:320px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_5 {
|
||||
padding-right:400px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_6 {
|
||||
padding-right:480px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_7 {
|
||||
padding-right:560px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_8 {
|
||||
padding-right:640px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_9 {
|
||||
padding-right:720px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_10 {
|
||||
padding-right:800px;
|
||||
}
|
||||
|
||||
.container_12 .suffix_11 {
|
||||
padding-right:880px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Push Space >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .push_1 {
|
||||
left:80px;
|
||||
}
|
||||
|
||||
.container_12 .push_2 {
|
||||
left:160px;
|
||||
}
|
||||
|
||||
.container_12 .push_3 {
|
||||
left:240px;
|
||||
}
|
||||
|
||||
.container_12 .push_4 {
|
||||
left:320px;
|
||||
}
|
||||
|
||||
.container_12 .push_5 {
|
||||
left:400px;
|
||||
}
|
||||
|
||||
.container_12 .push_6 {
|
||||
left:480px;
|
||||
}
|
||||
|
||||
.container_12 .push_7 {
|
||||
left:560px;
|
||||
}
|
||||
|
||||
.container_12 .push_8 {
|
||||
left:640px;
|
||||
}
|
||||
|
||||
.container_12 .push_9 {
|
||||
left:720px;
|
||||
}
|
||||
|
||||
.container_12 .push_10 {
|
||||
left:800px;
|
||||
}
|
||||
|
||||
.container_12 .push_11 {
|
||||
left:880px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Pull Space >> 12 Columns
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
.container_12 .pull_1 {
|
||||
left:-80px;
|
||||
}
|
||||
|
||||
.container_12 .pull_2 {
|
||||
left:-160px;
|
||||
}
|
||||
|
||||
.container_12 .pull_3 {
|
||||
left:-240px;
|
||||
}
|
||||
|
||||
.container_12 .pull_4 {
|
||||
left:-320px;
|
||||
}
|
||||
|
||||
.container_12 .pull_5 {
|
||||
left:-400px;
|
||||
}
|
||||
|
||||
.container_12 .pull_6 {
|
||||
left:-480px;
|
||||
}
|
||||
|
||||
.container_12 .pull_7 {
|
||||
left:-560px;
|
||||
}
|
||||
|
||||
.container_12 .pull_8 {
|
||||
left:-640px;
|
||||
}
|
||||
|
||||
.container_12 .pull_9 {
|
||||
left:-720px;
|
||||
}
|
||||
|
||||
.container_12 .pull_10 {
|
||||
left:-800px;
|
||||
}
|
||||
|
||||
.container_12 .pull_11 {
|
||||
left:-880px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* `Clear Floated Elements
|
||||
----------------------------------------------------------------------------------------------------*/
|
||||
|
||||
/* http://sonspring.com/journal/clearing-floats */
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* http://www.yuiblog.com/blog/2010/09/27/clearfix-reloaded-overflowhidden-demystified */
|
||||
|
||||
.clearfix:before,
|
||||
.clearfix:after {
|
||||
content: '\0020';
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.clearfix:after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
/*
|
||||
The following zoom:1 rule is specifically for IE6 + IE7.
|
||||
Move to separate stylesheet if invalid CSS is a problem.
|
||||
*/
|
||||
|
||||
.clearfix {
|
||||
zoom: 1;
|
||||
}
|
||||
69
template_static/style/layout.css
Normal file
69
template_static/style/layout.css
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
** Base layout:
|
||||
** Grid layout + vertical sizing classes sized to match
|
||||
*/
|
||||
|
||||
/* Grid layout based on (http://960.gs/) */
|
||||
@import url(grid.css);
|
||||
|
||||
/* Vertical classes */
|
||||
|
||||
.grid_1, .vert_1,
|
||||
.grid_2, .vert_2,
|
||||
.grid_3, .vert_3,
|
||||
.grid_4, .vert_4,
|
||||
.grid_5, .vert_5,
|
||||
.grid_6, .vert_6,
|
||||
.grid_7, .vert_7,
|
||||
.grid_8, .vert_8,
|
||||
.grid_9, .vert_9,
|
||||
.grid_10, .vert_10,
|
||||
.grid_11, .vert_11,
|
||||
.grid_12, .vert_12 {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
.container_12 .vert_1 { height:60px; }
|
||||
.container_12 .vert_2 { height:140px; }
|
||||
.container_12 .vert_3 { height:220px; }
|
||||
.container_12 .vert_4 { height:300px; }
|
||||
.container_12 .vert_5 { height:380px; }
|
||||
.container_12 .vert_6 { height:460px; }
|
||||
.container_12 .vert_7 { height:540px; }
|
||||
.container_12 .vert_8 { height:620px; }
|
||||
.container_12 .vert_9 { height:700px; }
|
||||
.container_12 .vert_10 { height:780px; }
|
||||
.container_12 .vert_11 { height:860px; }
|
||||
.container_12 .vert_12 { height:940px; }
|
||||
|
||||
|
||||
/* Layout details */
|
||||
|
||||
p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Log block */
|
||||
|
||||
.block.log {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
/* Tweets block */
|
||||
|
||||
.tweet-list * {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tweet-list.stream-items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
160
template_static/style/theme.css
Normal file
160
template_static/style/theme.css
Normal file
@@ -0,0 +1,160 @@
|
||||
/* Basic style & theme*/
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #eee;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 2px;
|
||||
padding: 0 0.2em;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0.5em 1.5em;
|
||||
background-color: #eee;
|
||||
border-top: 1px solid #ddd;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* Devevlopment helpers */
|
||||
|
||||
.debug_red { background-color: rgba(255,0,0,0.5); }
|
||||
.debug_green { background-color: rgba(0,255,0,0.5); }
|
||||
.debug_blue { background-color: rgba(0,0,255,0.5); }
|
||||
|
||||
|
||||
/* Tweets block */
|
||||
|
||||
.tweet-list.stream-items {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
list-style: none;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
font-family: arial, sans-serif;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tweet-list .stream-item {
|
||||
background: #fff;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.tweet-list .stream-item:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.tweet-list li.stream-item {
|
||||
line-height: inherit
|
||||
}
|
||||
|
||||
.tweet-list .tweet {
|
||||
position: relative;
|
||||
min-height: 51px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.tweet-list .stream-item + .stream-item {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tweet-list .tweet p {
|
||||
word-wrap: break-word
|
||||
}
|
||||
|
||||
.tweet-list .tweet .details {
|
||||
display: inline-block;
|
||||
margin-right: 2px
|
||||
}
|
||||
|
||||
.tweet-list .tweet .context a {
|
||||
color: #999
|
||||
}
|
||||
|
||||
.tweet-list .stream-item .content {
|
||||
margin-left: 58px
|
||||
}
|
||||
|
||||
.tweet-list .stream-item-header .avatar {
|
||||
float: left;
|
||||
margin-top: 3px;
|
||||
margin-left: -58px
|
||||
}
|
||||
.tweet-list .account-group {
|
||||
color: #999
|
||||
}
|
||||
|
||||
.tweet-list a {
|
||||
color: #0084b4;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
.tweet-list a:focus {
|
||||
outline: 0
|
||||
}
|
||||
|
||||
.tweet-list a:hover,
|
||||
.tweet-list a:focus {
|
||||
color: #0084b4;
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.tweet-list a.account-group:hover,
|
||||
.tweet-list a.account-group:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tweet-list a.account-group:hover .fullname,
|
||||
.tweet-list a.account-group:focus .fullname {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tweet-list .avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 5px;
|
||||
-moz-force-broken-image-icon: 1
|
||||
}
|
||||
|
||||
.tweet-list .fullname {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tweet-list .username {
|
||||
font-size: 12px;
|
||||
color: #999
|
||||
}
|
||||
|
||||
.tweet-list .username s {
|
||||
color: #bbb
|
||||
}
|
||||
|
||||
.tweet-list s {
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
.tweet-list b {
|
||||
font-weight: normal
|
||||
}
|
||||
|
||||
.tweet-list .tweet .time {
|
||||
position: relative;
|
||||
float: right;
|
||||
margin-top: 1px;
|
||||
color: #bbb
|
||||
}
|
||||
|
||||
.tweet-list .tweet-timestamp {
|
||||
color: #999
|
||||
}
|
||||
|
||||
.tweet-list .tweet .tweet-text {
|
||||
white-space: pre-wrap
|
||||
}
|
||||
49
template_static/style/wordcloud.css
Normal file
49
template_static/style/wordcloud.css
Normal file
@@ -0,0 +1,49 @@
|
||||
/* fonts */
|
||||
|
||||
div.jqcloud {
|
||||
font-family: "Helvetica", "Arial", sans-serif;
|
||||
font-size: 10px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
div.jqcloud a {
|
||||
font-size: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
div.jqcloud span.w10 { font-size: 550%; }
|
||||
div.jqcloud span.w9 { font-size: 500%; }
|
||||
div.jqcloud span.w8 { font-size: 450%; }
|
||||
div.jqcloud span.w7 { font-size: 400%; }
|
||||
div.jqcloud span.w6 { font-size: 350%; }
|
||||
div.jqcloud span.w5 { font-size: 300%; }
|
||||
div.jqcloud span.w4 { font-size: 250%; }
|
||||
div.jqcloud span.w3 { font-size: 200%; }
|
||||
div.jqcloud span.w2 { font-size: 150%; }
|
||||
div.jqcloud span.w1 { font-size: 100%; }
|
||||
|
||||
/* colors */
|
||||
|
||||
div.jqcloud { color: #09f; }
|
||||
div.jqcloud a { color: inherit; }
|
||||
div.jqcloud a:hover { color: #0df; }
|
||||
div.jqcloud a:hover { color: #0cf; }
|
||||
div.jqcloud span.w10 { color: #0cf; }
|
||||
div.jqcloud span.w9 { color: #0cf; }
|
||||
div.jqcloud span.w8 { color: #0cf; }
|
||||
div.jqcloud span.w7 { color: #39d; }
|
||||
div.jqcloud span.w6 { color: #90c5f0; }
|
||||
div.jqcloud span.w5 { color: #90a0dd; }
|
||||
div.jqcloud span.w4 { color: #90c5f0; }
|
||||
div.jqcloud span.w3 { color: #a0ddff; }
|
||||
div.jqcloud span.w2 { color: #99ccee; }
|
||||
div.jqcloud span.w1 { color: #aab5f0; }
|
||||
|
||||
/* layout */
|
||||
|
||||
div.jqcloud {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div.jqcloud span { padding: 0; }
|
||||
57
tweet2arff.py
Executable file
57
tweet2arff.py
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import eca.arff
|
||||
|
||||
def file_type(mode):
|
||||
"""Acts as an output file type for argparse. Always uses utf-8 encoding."""
|
||||
def handler(name):
|
||||
if name == '-':
|
||||
import sys
|
||||
if 'r' in mode:
|
||||
return sys.stdin
|
||||
elif 'w' in mode:
|
||||
return sys.stdout
|
||||
else:
|
||||
raise argparse.ArgumentTypeError("can't use mode '{}' for stdin/stdout".format(mode))
|
||||
|
||||
try:
|
||||
return open(name, mode, encoding='utf-8')
|
||||
except OSError as e:
|
||||
raise argparse.ArgumentTypeError("can't open '{}': {}".format(name, e))
|
||||
|
||||
return handler
|
||||
|
||||
def rows(tweets):
|
||||
"""
|
||||
This Generator function takes an opened data file with one JSON object
|
||||
representing a tweet per line, and generates a row for each tweet.
|
||||
"""
|
||||
for line in tweets:
|
||||
tweet = json.loads(line)
|
||||
yield {'tweet': tweet['text']}
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main program entry point.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description='Tweet data to ARFF converter')
|
||||
parser.add_argument('file', type=file_type('r'), help='Twitter data source')
|
||||
parser.add_argument('output', type=file_type('w'), help='Output file')
|
||||
args = parser.parse_args()
|
||||
|
||||
# attribute description
|
||||
fields = [
|
||||
eca.arff.Field('tweet', eca.arff.Text()),
|
||||
eca.arff.Field('@@class@@', eca.arff.Nominal(['a','b','c']))
|
||||
]
|
||||
|
||||
# create item generator
|
||||
items = rows(args.file)
|
||||
|
||||
eca.arff.save(args.output, fields, items, name='ARFF for '+args.file.name)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user