This tutorial explains how to use the dual_canvas_helper
jQuery plugin to implement
diagrams that respond to mouse events. The tutorial explains how to create the diagrams
as "standalone pure Javascript" which can be embedded in any HTML page and also shows
how to implement the diagrams inside a Jupyter IPython notebook.
The dual canvas
implementation can be used to develop
complex interactive visualizations such as the
rectangles proof of concept which presents
a two category bar chart visualization supporting animated repositioning
and mouse over and drag interactions.
The tutorial is intended for programmers who have some knowledge of Javascript, Python Jupyter, and HTML 5 libraries such as jQuery. The tutorial also makes use of proxy widget features without explaining them in great detail -- please see the proxy widget tutorial for additional discussion.
The dual canvas implementation is a jQueryUI
plugin which is built using jQuery
and some other plugin functionality. It requires several Javascript libraries
to be loaded.
jQuery
,jQueryUI
,js/canvas_2d_widget_helper.js
-- the single canvas jQuery
plugin, andjs/dual_canvas_helper.js
-- the dual canvas jQuery
plugin.The Python wrapper dual_canvas.py
makes the dual canvas conveniently available
as IPython widgets, and it provides a convenience function for loading the
required javascript libraries: dual_canvas.load_requirements()
. These requirements
are loaded automagically by the jp_doodle.dual_canvas.DualCanvasWidget
wrapper widget.
Below we use jp_proxy_widget.JSProxyWidget
directly instead of DualCanvasWidget
in order to show code fragments that can also run as stand alone Javascript, and that
is why we load the requirements explicitly in the next cell.
from jp_doodle import dual_canvas
dual_canvas.load_requirements()
The dual_canvas_helper
Plugin attaches a factory function to
jQuery elements element.dual_canvas_helper(config)
which creates
an HTML canvas area associated with the element and attached as a child
of the element. Methods attached to the element like element.rect({...})
draw shapes onto the drawing area.
Below we attach a canvas to a jp_proxy_widget
IPython widget and
draw a number of objects onto the canvas using Javascript.
from IPython.display import HTML, display
import jp_proxy_widget
# Create a proxy widget to contain the canvas.
canvas0 = jp_proxy_widget.JSProxyWidget()
# Load javascript for the widget.
canvas0.js_init("""
debugger;
// Empty the js_proxy jQuery element associated with the widget.
element.empty();
// Attach an information div to the element to display event feedback.
var info_area = $("<div>Dual canvas feedback will show here.</div>").appendTo(element);
// Attach a dual canvas associated with the element as a child of the element
// configured with width 400 and height 200.
var config = {
width: 400,
height: 200,
};
element.dual_canvas_helper(config);
// Draw some named elements on the canvas.
// A filled yellow circle (disk) named "Colonel Mustard
element.circle({name: "Colonel Mustard", x:100, y:150, r:90, color:"yellow"});
// A filled red rectangle named "Miss Scarlett"
element.rect({name: "Miss Scarlett", x:100, y:130, w:100, h:20, color: "red"});
// An unfilled white circle named "Mrs. White"
element.circle({
name: "Mrs. White", x:100, y:150, r:58, fill:false,
color:"white", lineWidth: 14});
// An unfilled blue rectangle named Mrs. Peacock
element.rect({
name: "Mrs. Peacock", x:40, y:110, w:100, h:20,
color: "blue", lineWidth: 10, degrees:70, fill:false});
// A line segment named "Professor Plum".
element.line({
name: "Professor Plum", x1:190, y1:100, x2:10, y2:200,
color:"purple", lineWidth: 20})
// A brown filled polygon (triangle) named Micky
element.polygon({
name: "Micky",
points: [[210, 10], [210, 110], [290, 60]],
color: "brown",
})
// A green polyline named Mr. Green
element.polygon({
name: "Mr. Green", fill:false, close:false, color: "green",
lineWidth: 14, points: [[210, 10], [210, 110], [290, 60]]
})
// A magenta text string display named Pluto
element.text({
name: "Pluto", text: "The Republic", font: "20px Arial",
x: 20, y:20, degrees: 5, color:"magenta"
})
// Mandrill eyes from a remote image
var mandrill_url = "http://sipi.usc.edu/database/preview/misc/4.2.03.png";
//var mandrill_url = "mandrill.png"
element.name_image_url("mandrill", mandrill_url);
// just the eyes, not the whole image
element.named_image({
name: "mandrill eyes",
image_name: "mandrill", x:220, y:170, w:80, h:30,
sx:30, sy:15, sWidth:140, sHeight:20
})
// Center and scale the figure to fit in the available area.
element.fit()
// Attach a mouse move event which indicates what object the mouse is over.
var on_mouse_move = function(event) {
if (event.canvas_name) {
info_area.html("<div>You are over the object named " + event.canvas_name + "</div>");
} else {
info_area.html("<div>You are not over anybody.</div>");
}
};
element.on_canvas_event("mousemove", on_mouse_move);
$("<div>Please mouse over the canvas area to see event feedback.</div>").appendTo(element)
""")
# Show the canvas
display(canvas0)
The dual canvas
implementation maintains a sequence of
drawn objects in order to support redrawing the canvas.
Every redraw clears the canvas and then iterates through the object list
to draw each of the objects again in the order they were added to the
canvas.
Furthermore named objects may be modified or deleted in order to change the canvas before a redraw. By modifying and redrawing a canvas a program can implement animations and other interactive features. Objects created with no name specified cannot be modified. Changes to objects or object deletions automatically trigger a canvas redraw.
Below we draw a clock face with numbers and add a seconds hand.
We animate rotation for the seconds hand by updating the degrees
rotation
attribute for each animation frame based on the current time.
We also add or delete a red circle for a blinking dot effect every other second. The blinking red circle sits atop a fixed yellow circle.
# Create a proxy widget to contain the canvas.
canvas1 = jp_proxy_widget.JSProxyWidget()
# Load javascript for the widget.
canvas1.js_init("""
// Empty the js_proxy jQuery element associated with the widget.
element.empty();
// Attach a dual canvas associated with the element as a child of the element
// configured with width 400 and height 400.
var config = {
width: 400,
height: 400,
};
element.dual_canvas_helper(config);
// Some math...
five_seconds = Math.PI / 6.0;
twelve_oclock = Math.PI / 2.0;
outer_radius = 100;
inner_radius = 80;
// Draw the clock face.
element.circle({x:0, y:0, r:100, color:"#dcf"});
element.circle({x:0, y:0, r:100, color:"#449", fill:false, lineWidth:10});
// Draw the numbers on the clock.
for (var i=1; i<=12; i++) {
var angle = twelve_oclock - i * five_seconds;
element.text({
text: ""+i, font: "20px Arial", color: "#937", align: "center",
x: Math.cos(angle)*inner_radius, y: Math.sin(angle)*inner_radius - 7})
}
// Draw a "seconds hand" and name it so we can change it later.
element.rect({
name: "seconds hand",
x:0, y:0, w: inner_radius-15, h:5,
color: "#937", degrees: 90});
// Add a background yellow dot. Note that there is no blinking dot yet.
var dot_visible = false;
element.circle({name: "background dot", x:100, y:100, r:12, color:"yellow"});
// Every animation frame, adjust the seconds hand using the time.
// Also add or delete a blinking dot every other second.
var animate = function () {
var seconds = ((new Date()).getTime() * 0.001) % 60;
var degrees = - 6 * seconds;
// Adjust the seconds hand.
element.change_element("seconds hand", {degrees: degrees});
// every other second create or delete the blinking dot
if ((seconds % 2) < 1) {
if (!dot_visible) {
// Add the blinking dot.
element.circle({name: "blinking dot", x:100, y:100, r:10, color:"red"});
dot_visible = true;
}
} else if (dot_visible) {
// Remove the blinking dot.
element.forget_objects(["blinking dot"]);
dot_visible = false;
}
// Repeat the animation again on the next animation iteration.
requestAnimationFrame(animate);
}
// Adjust the canvas coordinate transform to center the drawn objects on the canvas.
element.fit(null, 10); // 10 pixel margin
// Start the animation
animate();
""")
display(canvas1)
The implementation is called dual canvas
because there are actually
two canvas objects created for every dual canvas
instance: a visible
canvas which shows the drawn objects and an invisible canvas which uses
color indexing tricks to implement mouse event interactions bound
to individual named drawn elements.
The standard HTML5 canvas does not provide any way to directly associate a mouse event handler with a drawn figure on a canvas -- only the whole canvas receives the event.
The dual canvas
implementation finds named objects associated with
a mouse click (or other mouse events) by determining the psuedocolor
under the mouse click in the invisible canvas and looking up the name of the
object associated with the pseudocolor in an internal data structure.
Objects with no names cannot be bound to events because they don't appear
in the invisible canvas.
Below we draw some objects and make the invisible canvas visible. The revealed invisible canvas appears below the visible canvas.
# Create a proxy widget to contain the canvas.
canvas2 = jp_proxy_widget.JSProxyWidget()
# Load javascript for the widget.
canvas2.js_init("""
// Empty the js_proxy jQuery element associated with the widget.
element.empty();
// Attach a dual canvas associated with the element as a child of the element
// configured with width 400 and height 200.
var config = {
width: 200,
height: 100,
};
element.dual_canvas_helper(config);
// Draw some elements on the canvas.
// A filled yellow circle (disk) named "Colonel Mustard
element.circle({name: "Colonel Mustard", x:100, y:150, r:90, color:"yellow"});
// A filled red rectangle named "Miss Scarlett"
element.rect({name: "Miss Scarlett", x:100, y:130, w:100, h:20, color: "red"});
// An unfilled blue rectangle named Mrs. Peacock
element.line({
name: "Professor Plum", x1:190, y1:100, x2:10, y2:200,
color:"purple", lineWidth: 20})
// A brown filled polygon (triangle) named Micky
element.polygon({
name: "Micky",
points: [[210, 10], [210, 110], [290, 60]],
color: "brown",
});
// A magenta text named Pluto
element.text({
name: "Pluto", text: "The Republic", font: "20px Arial",
x: 20, y:20, degrees: 5, color:"magenta"
});
// Cover part of the canvas with a semi-transparent rectangle with NO NAME
element.rect({x:20, y:20, w:200, h:200, color: "rgba(100,100,100,0.5)"});
element.fit();
// Reveal the invisible canvas
element.invisible_canvas.show();
""")
display(canvas2)
Note that the semi-transparent gray rectangle which obscures much of the drawing does not appear in the invisible canvas because it has no name. Also note that the text "the Republic" appears as a rectangle in the invisible canvas to allow the rectangle over the text to associate to the text.
In the event that there are "two objects" under a mouse event only the one that is "on top" will be bound to the event because the invisible canvas has no knowledge of what object is "underneath" the top object.
A program can bind event handlers to the whole canvas. If the event happens
over a named object the event will contain the object name as event.canvas_name
and the object description as event.object_info
.
Below we draw a number of objects on a canvas and define mousedown
and mouseup
events bound to the whole canvas which "pick up" and "drop" an object respectively.
If an object has been picked up a mousemove
event will move the object to the
current cursor location.
# Create a proxy widget to contain the canvas.
canvas3 = jp_proxy_widget.JSProxyWidget()
# Load javascript for the widget.
canvas3.js_init("""
// Empty the js_proxy jQuery element associated with the widget.
element.empty();
// Attach an information div to the element to display event feedback.
var info_area = $("<div>Please mouse-down and drag objects below.</div>").appendTo(element);
// Attach a dual canvas associated with the element as a child of the element
// configured with width 400 and height 200.
var config = {
width: 400,
height: 200,
};
element.dual_canvas_helper(config);
// Draw some elements on the canvas.
// A filled yellow circle (disk) named "Colonel Mustard
element.circle({name: "Colonel Mustard", x:100, y:150, r:90, color:"yellow"});
// A filled red rectangle named "Miss Scarlett"
element.rect({name: "Miss Scarlett", x:100, y:130, w:100, h:20, color: "red"});
// A magenta text named Pluto
element.text({
name: "Pluto", text: "The Republic", font: "20px Arial",
x: 20, y:20, degrees: 5, color:"magenta"
});
// Mandrill eyes from a remote image
var mandrill_url = "http://sipi.usc.edu/database/preview/misc/4.2.03.png";
// This local link will work in "classic notebook" but not Jupyter Lab.
//var mandrill_url = "mandrill.png"
// Note: This local image URL reference does not work in Jupyter lab
// you would need to use a URL that references /files/ to make it work.
element.name_image_url("mandrill", mandrill_url);
// just the eyes, not the whole image
element.named_image({
name: "mandrill eyes",
image_name: "mandrill", x:220, y:170, w:80, h:30,
sx:30, sy:15, sWidth:140, sHeight:20
})
// Cover part of the canvas with a semi-transparent rectangle with NO NAME
element.rect({x:20, y:20, w:200, h:200, color: "rgba(100,100,100,0.5)"});
element.fit(null, 10);
// Define a variable for the picked-up object
var picked_up_object = null;
// Attach a mousedown event which picks up any named object under the mouse.
var on_mouse_down = function(event) {
if (event.canvas_name) {
info_area.html("<div>Picking up the object named " + event.canvas_name + "</div>");
picked_up_object = event.canvas_name;
} else {
info_area.html("<div>No object to pick up.</div>");
}
};
element.on_canvas_event("mousedown", on_mouse_down);
// Attach a mousemove event which moves any picked up object.
var on_mouse_move = function(event) {
if (picked_up_object) {
var loc = element.event_model_location(event);
info_area.html("<div>"+picked_up_object+":"+loc.x+","+loc.y+"</div>");
element.change_element(picked_up_object, {"x":loc.x, "y":loc.y});
} else if (event.canvas_name) {
info_area.html("<div>You are over the object named " + event.canvas_name + "</div>");
} else {
info_area.html("<div>You are not over anybody.</div>");
}
};
element.on_canvas_event("mousemove", on_mouse_move);
// Attach a mouseup event which "drops" the current picked up object and re-fits the canvas.
var on_mouse_up = function(event) {
info_area.html("<div>Dropping " + picked_up_object + ".</div>");
picked_up_object = null;
// refit the canvas to the new object configuration.
element.fit(null, 10)
};
element.on_canvas_event("mouseup", on_mouse_up);
$("<div>Please mouse down and drag over the colorful objects to move them.</div>")
.appendTo(element);
""")
display(canvas3)
Note that the gray semi-translucent rectangle does not respond to events because it has no name, even though it sits on top of the other objects in the visible canvas. As an exercise you could rerun the code cell after giving the gray rectangle a name to see different behavior.
In complex visualizations it is convenient to associate mouse event handlers to drawing components individually.
Below we create some text elements that behave differently to click events.
# Create a proxy widget to contain the canvas.
canvas4 = jp_proxy_widget.JSProxyWidget()
# Load javascript for the widget.
canvas4.js_init("""
// Empty the js_proxy jQuery element associated with the widget.
element.empty();
// Attach an information div to the element to display event feedback.
var info_area = $("<div>Please click on text areas below.</div>").appendTo(element);
// Attach a dual canvas associated with the element as a child of the element
// configured with width 400 and height 200.
var config = {
width: 400,
height: 200,
};
element.dual_canvas_helper(config);
element.text({
name: "bigger", text: "Click to enlarge",
font: "20px Arial", x: 20, y:20, degrees: 5, color:"magenta"
});
var enlarge = function(event) {
element.change_element("bigger", {font: "40px Arial"});
// change it back 5 seconds later
setTimeout(function () { element.change_element("bigger", {font: "20px Arial"}); }, 5000)
};
element.on_canvas_event("click", enlarge, "bigger");
element.text({
name: "redden", text: "Click to redden",
font: "20px Arial", x: 20, y:50, degrees: 5, color:"green"
});
var redden = function(event) {
element.change_element("redden", {color: "red"});
// change it back 5 seconds later
setTimeout(function () { element.change_element("redden", {color: "green"}); }, 5000)
};
element.on_canvas_event("click", redden, "redden");
element.text({
name: "font", text: "Click to change font",
font: "20px Arial", x: 20, y:80, degrees: 5, color:"magenta"
});
var change_font = function(event) {
element.change_element("font", {font: "20px Courier"});
// change it back 5 seconds later
setTimeout(function () { element.change_element("font", {font: "20px Arial"}); }, 5000)
};
element.on_canvas_event("click", change_font, "font");
element.text({
name: "rotate", text: "Click to rotate",
font: "20px Arial", x: 20, y:120, degrees: 5, color:"magenta"
});
var rotate = function(event) {
element.change_element("rotate", {degrees: -15});
// change it back 5 seconds later
setTimeout(function () { element.change_element("rotate", {degrees: 5}); }, 5000)
};
element.on_canvas_event("click", rotate, "rotate");
element.text({
name: "vanish", text: "Click to disappear",
font: "20px Arial", x: 20, y:150, degrees: 5, color:"magenta"
});
var disappear = function(event) {
element.change_element("vanish", {hide: true});
// change it back 5 seconds later
setTimeout(function () { element.change_element("vanish", {hide: false}); }, 5000)
};
element.on_canvas_event("click", disappear, "vanish");
""")
display(canvas4)
# http://population.us/states
# Name population density change
STATES = """
Alabama 4858979 92.7 0.329
Alaska 738432 1.1 0.782
Arizona 6828065 59.9 1.329
Arkansas 2978204 56 0.424
California 39144818 239.1 0.995
Colorado 5456574 52.4 1.645
Connecticut 3590886 647.8 0.094
Delaware 945934 380.1 1.047
District of Columbia 672228 9836.5 2.241
Florida 20271272 308.3 1.517
Georgia 10214860 171.9 1.065
Hawaii 1431603 131 1.027
Idaho 1654930 19.8 1.09
Illinois 12859995 222.1 0.046
Indiana 6619680 181.8 0.416
Iowa 3123899 55.5 0.504
Kansas 2911641 35.4 0.407
Kentucky 4425092 109.5 0.392
Louisiana 4670724 89.2 0.599
Maine 1329328 37.6 0.015
Maryland 6006401 484.2 0.794
Massachusetts 6794422 643.8 0.743
Michigan 9922576 102.6 0.079
Minnesota 5489594 63.1 0.691
Mississippi 2992333 61.8 0.168
Missouri 6083672 87.3 0.314
Montana 1032949 7 0.865
Nebraska 1896190 24.5 0.753
Nevada 2890845 26.1 1.371
New Hampshire 1330608 142.3 0.214
New Jersey 8958013 1027 0.375
New Mexico 2085109 17.1 0.251
New York 19795791 362.9 0.427
North Carolina 10042802 186.6 1.042
North Dakota 756927 10.7 2.391
Ohio 11613423 259.1 0.133
Oklahoma 3911338 56 0.839
Oregon 4028977 41 1.012
Pennsylvania 12802503 278 0.157
Rhode Island 1056298 683.7 0.071
South Carolina 4896146 152.9 1.144
South Dakota 858469 11.1 1.065
Tennessee 6600299 156.6 0.789
Texas 27469114 102.3 1.783
Utah 2995919 35.3 1.625
Vermont 626042 65.1 0.01
Virginia 8382993 196 0.937
Washington 7170351 100.6 1.292
West Virginia 1844128 76.1 -0.096
Wisconsin 5771337 88.1 0.295
Wyoming 586107 6 0.785
"""
states_mappings = []
for line in STATES.strip().split("\n"):
sline = line.split("\t")
name = sline[0]
[pop, density, change] = [float(x) for x in sline[1:]]
states_mappings.append((density, dict(population=pop, density=density, change=change, name=name)))
#states_mappings.sort()
states_mappings.sort(key=lambda x: x[0])
states_mappings.reverse()
states_info = [t[1] for t in states_mappings]
def extrema(floats):
minimum = min(floats)
maximum = max(floats)
span = maximum - minimum
return dict(minimum=minimum, maximum=maximum, span=span)
pop_stats = extrema([d["population"] for d in states_info])
density_stats = extrema([d["density"] for d in states_info])
change_stats = extrema([d["change"] for d in states_info])
side = 300.0
# Use x axis as scaled population
x_stats = pop_stats
scale_x = side / pop_stats["span"]
for d in states_info:
d["x"] = d["population"]
# Use y axis as scaled population change
y_stats = change_stats
scale_y = side / change_stats["span"]
for d in states_info:
d["y"] = d["change"]
# Use radius as scaled density
max_radius = 25.0
scale_radius = max_radius / density_stats["maximum"]
for d in states_info:
d["r"] = scale_radius * d["density"] + 5;
# color by all 3
def convert255(value, stats):
return int((value - stats["minimum"]) * 255.0 / stats["span"])
for d in states_info:
r = convert255(d["density"], density_stats)
g = convert255(d["population"], pop_stats)
b = convert255(d["change"], change_stats)
d["color"] = "rgb(%s,%s,%s)" % (r,g,b)
def state_chart():
chart = jp_proxy_widget.JSProxyWidget()
# Load javascript for the widget.
chart.js_init("""
// Empty the js_proxy jQuery element associated with the widget.
element.empty();
// Configure a dual canvas
var config = {
width: 400,
height: 400,
};
element.dual_canvas_helper(config);
element.text({text: "population", x:200, y:-100, font: "20px Arial", align:"center"})
element.text({text: "% change", x:-70, y: 200, font: "20px Arial", degrees:-90, align:"center"})
var frame = element.frame_region(0, 0, 400, 400,
x_stats.minimum, y_stats.minimum, x_stats.maximum, y_stats.maximum);
//var frame = element.rframe(scale_x, scale_y,
// -x_stats.minimum*scale_x, -y_stats.minimum*scale_y);
//var frame = element.rframe(scale_x, scale_y, 0, 0);
//debugger;
//element.text({text: "population", x:30, y: -30})
for (var i=0; i<states_info.length; i++) {
frame.circle(states_info[i])
}
var low_x = x_stats.minimum - 0.05 * x_stats.span;
var low_y = y_stats.minimum - 0.05 * y_stats.span;
frame.lower_left_axes({
skip_anchor: false,
min_x: low_x, //x_stats.minimum,
max_x: x_stats.maximum,
min_y: low_y, //y_stats.minimum,
max_y: y_stats.maximum
});
/*
frame.left_axis({
min_value: low_y,
max_value: y_stats.maximum,
axis_origin: {x: low_x, y:0},
add_end_points: true
});
frame.bottom_axis({
min_value: low_x,
max_value: x_stats.maximum,
axis_origin: {x: 0, y: low_y},
add_end_points: true
});
*/
element.fit()
""", states_info=states_info, x_stats=x_stats, y_stats=y_stats, scale_x=scale_x, scale_y=scale_y)
return chart
canvas5 = state_chart()
display(canvas5)
canvas6 = state_chart()
canvas6.check_jquery()
canvas6.js_init("""
//debugger;
var dialog = $("<div>dialog text</div>").appendTo(element);
dialog.dialog();
var mouse_over = function(event) {
var info = event.object_info;
if (info) {
debugger;
// http://api.jqueryui.com/position/
var pos = { my: "left+5 top+5", at: "left bottom", of: event }
dialog.dialog("option", "position", pos);
//dialog.html("<div>" + info.name, + "</div
var html = (
"<div>"+
"<div>"+
info.name+
"</div>"+
"<div>density: "+
info.density+
"</div>"+
"<div>population: "+
info.population+
"</div>"+
"<div>change: "+
info.change+
"</div>"+
"</div>")
dialog.html(html)
dialog.dialog("open");
} else {
dialog.dialog("close");
}
};
element.on_canvas_event("mousemove", mouse_over);
dialog.dialog("close");
""")
display(canvas6)