Add documentation to wasm_backend.py

This commit is contained in:
Michael Droettboom 2018-05-24 10:03:05 -04:00
parent 663ff90695
commit 6319140bb6
1 changed files with 121 additions and 70 deletions

View File

@ -1,3 +1,17 @@
"""
A matplotlib backend that renders to an HTML5 canvas in the same thread.
The Agg backend is used for the actual rendering underneath, and renders the
buffer to the HTML5 canvas. This happens with only a single copy of the data
into the Canvas -- passing the data from Python to Javascript requires no
copies.
See matplotlib.backend_bases for documentation for most of the methods, since
this primarily is just overriding methods in the base class.
"""
# TODO: Figure resizing support
import base64
import io
import math
@ -6,12 +20,225 @@ from matplotlib.backends import backend_agg
from matplotlib.backend_bases import _Backend
from matplotlib import backend_bases, _png
from js import iodide
from js import document
from js import window
from js import ImageData
class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
supports_blit = False
def __init__(self, *args, **kwargs):
backend_agg.FigureCanvasAgg.__init__(self, *args, **kwargs)
self._idle_scheduled = False
self._id = "matplotlib_" + hex(id(self))[2:]
self._title = ''
self._ratio = 1
def get_element(self, name):
"""
Looks up an HTMLElement created for this figure.
"""
# TODO: Should we store a reference here instead of always looking it
# up? I'm a little concerned about weird Python/JS
# cross-memory-management issues...
return document.getElementById(self._id + name)
def get_dpi_ratio(self, context):
"""
Gets the ratio of physical pixels to logical pixels for the given HTML
Canvas context.
This is typically 2 on a HiDPI ("Retina") display, and 1 otherwise.
"""
backing_store = (
context.backingStorePixelRatio or
context.webkitBackingStorePixel or
context.mozBackingStorePixelRatio or
context.msBackingStorePixelRatio or
context.oBackingStorePixelRatio or
context.backendStorePixelRatio or
1
)
return (window.devicePixelRatio or 1) / backing_store
def create_root_element(self):
# Designed to be overridden by subclasses for use in contexts other
# than iodide.
from js import iodide
return iodide.output.element('div')
def show(self):
# If we've already shown this canvas elsewhere, don't create a new one,
# just reuse it and scroll to the existing one.
existing = self.get_element('')
if existing is not None:
self.draw_idle()
existing.scrollIntoView()
return
# Disable the right-click context menu.
# Doesn't work in all browsers.
def ignore(event):
event.preventDefault()
return False
window.addEventListener('contextmenu', ignore)
# Create the main canvas and determine the physical to logical pixel
# ratio
canvas = document.createElement('canvas')
context = canvas.getContext('2d')
self._ratio = self.get_dpi_ratio(context)
if self._ratio != 1:
self.figure.dpi *= self._ratio
width, height = self.get_width_height()
div = self.create_root_element()
div.setAttribute('style', 'width: {}px'.format(width / self._ratio))
div.id = self._id
# The top bar
top = document.createElement('div')
top.id = self._id + 'top'
top.setAttribute('style', 'font-weight: bold; text-align: center')
top.textContent = self._title
div.appendChild(top)
# A div containing two canvases stacked on top of one another:
# - The bottom for rendering matplotlib content
# - The top for rendering interactive elements, such as the zoom
# rubberband
canvas_div = document.createElement('div')
canvas_div.setAttribute('style', 'position: relative')
canvas.id = self._id + 'canvas'
canvas.setAttribute('width', width)
canvas.setAttribute('height', height)
canvas.setAttribute(
'style', 'left: 0; top: 0; z-index: 0; outline: 0;' +
'width: {}px; height: {}px'.format(
width / self._ratio, height / self._ratio)
)
canvas_div.appendChild(canvas)
rubberband = document.createElement('canvas')
rubberband.id = self._id + 'rubberband'
rubberband.setAttribute('width', width)
rubberband.setAttribute('height', height)
rubberband.setAttribute(
'style', 'position: absolute; left: 0; top: 0; z-index: 0; outline: 0;' +
'width: {}px; height: {}px'.format(
width / self._ratio, height / self._ratio)
)
# Canvas must have a "tabindex" attr in order to receive keyboard events
rubberband.setAttribute('tabindex', '0')
# Event handlers are added to the canvas "on top", even though most of the
# activity happens in the canvas below.
rubberband.addEventListener('click', self.onclick)
rubberband.addEventListener('mousemove', self.onmousemove)
rubberband.addEventListener('mouseup', self.onmouseup)
rubberband.addEventListener('mousedown', self.onmousedown)
rubberband.addEventListener('mouseenter', self.onmouseenter)
rubberband.addEventListener('mouseleave', self.onmouseleave)
rubberband.addEventListener('keyup', self.onkeyup)
rubberband.addEventListener('keydown', self.onkeydown)
context = rubberband.getContext('2d')
context.strokeStyle = '#000000';
context.setLineDash([2, 2])
canvas_div.appendChild(rubberband)
div.appendChild(canvas_div)
# The bottom bar, with toolbar and message display
bottom = document.createElement('div')
toolbar = self.toolbar.get_element()
bottom.appendChild(toolbar)
message = document.createElement('div')
message.id = self._id + 'message'
message.setAttribute('style', 'min-height: 1.5em')
bottom.appendChild(message)
div.appendChild(bottom)
self.draw()
def draw(self):
# Render the figure using Agg
super().draw()
# Copy the image buffer to the canvas
width, height = self.get_width_height()
canvas = self.get_element('canvas')
image_data = ImageData.new(
self.buffer_rgba(),
width, height);
ctx = canvas.getContext("2d");
ctx.putImageData(image_data, 0, 0);
self._idle_scheduled = False
def draw_idle(self):
if not self._idle_scheduled:
self._idle_scheduled = True
window.setTimeout(self.draw, 0)
def set_message(self, message):
message_display = self.get_element('message')
if message_display is not None:
message_display.textContent = message
def _convert_mouse_event(self, event):
width, height = self.get_width_height()
x = (event.offsetX * self._ratio)
y = ((height / self._ratio) - event.offsetY) * self._ratio
button = event.button + 1
# Disable the right-click context menu in some browsers
if button == 3:
event.preventDefault()
event.stopPropagation()
if button == 2:
button = 3
return x, y, button
def onclick(self, event):
x, y, button = self._convert_mouse_event(event)
self.button_click_event(x, y, button, guiEvent=event)
def onmousemove(self, event):
x, y, button = self._convert_mouse_event(event)
self.motion_notify_event(x, y, guiEvent=event)
def onmouseup(self, event):
x, y, button = self._convert_mouse_event(event)
self.button_release_event(x, y, button, guiEvent=event)
def onmousedown(self, event):
x, y, button = self._convert_mouse_event(event)
self.button_press_event(x, y, button, guiEvent=event)
def onmouseenter(self, event):
window.addEventListener('contextmenu', ignore)
# When the mouse is over the figure, get keyboard focus
self.get_element('rubberband').focus()
self.enter_notify_event(guiEvent=event)
def onmouseleave(self, event):
# When the mouse leaves the figure, drop keyboard focus
self.get_element('rubberband').blur()
self.leave_notify_event(guiEvent=event)
def onscroll(self, event):
x, y, button = self._convert_mouse_event(event)
self.scroll_event(x, y, event.deltaX, guiEvent=event)
_cursor_map = {
0: 'pointer',
1: 'default',
2: 'crosshair',
3: 'move'
}
def set_cursor(self, cursor):
self.get_element('rubberband').style.cursor = self._cursor_map.get(cursor, 0)
# http://www.cambiaresearch.com/articles/15/javascript-char-codes-key-codes
_SHIFT_LUT = {
59: ':',
@ -74,193 +301,13 @@ _LUT = {
222: "'"
}
class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
supports_blit = False
def __init__(self, *args, **kwargs):
backend_agg.FigureCanvasAgg.__init__(self, *args, **kwargs)
self._idle_scheduled = False
self._id = "matplotlib_" + hex(id(self))[2:]
self._title = ''
self._ratio = 1
def get_element(self, name):
# TODO: Should we store a reference here instead of always looking it
# up? I'm a little concerned about weird Python/JS
# cross-memory-management issues...
return document.getElementById(self._id + name)
def get_dpi_ratio(self, context):
backing_store = (
context.backingStorePixelRatio or
context.webkitBackingStorePixel or
context.mozBackingStorePixelRatio or
context.msBackingStorePixelRatio or
context.oBackingStorePixelRatio or
context.backendStorePixelRatio or
1
)
return (window.devicePixelRatio or 1) / backing_store
def show(self):
existing = self.get_element('')
if existing is not None:
self.draw_idle()
existing.scrollIntoView()
return
def ignore(event):
event.preventDefault()
return False
window.addEventListener('contextmenu', ignore)
canvas = document.createElement('canvas')
context = canvas.getContext('2d')
self._ratio = self.get_dpi_ratio(context)
if self._ratio != 1:
self.figure.dpi *= self._ratio
renderer = self.get_renderer()
width, height = self.get_width_height()
div = iodide.output.element('div')
div.setAttribute('style', 'width: {}px'.format(width / self._ratio))
div.id = self._id
top = document.createElement('div')
top.id = self._id + 'top'
top.setAttribute('style', 'font-weight: bold; text-align: center')
top.textContent = self._title
div.appendChild(top)
canvas_div = document.createElement('div')
canvas_div.setAttribute('style', 'position: relative')
canvas.id = self._id + 'canvas'
canvas.setAttribute('width', width)
canvas.setAttribute('height', height)
canvas.setAttribute(
'style', 'left: 0; top: 0; z-index: 0; outline: 0;' +
'width: {}px; height: {}px'.format(
width / self._ratio, height / self._ratio)
)
canvas_div.appendChild(canvas)
rubberband = document.createElement('canvas')
rubberband.id = self._id + 'rubberband'
rubberband.setAttribute('width', width)
rubberband.setAttribute('height', height)
rubberband.setAttribute(
'style', 'position: absolute; left: 0; top: 0; z-index: 0; outline: 0;' +
'width: {}px; height: {}px'.format(
width / self._ratio, height / self._ratio)
)
rubberband.setAttribute('tabindex', '0')
rubberband.addEventListener('click', self.onclick)
rubberband.addEventListener('mousemove', self.onmousemove)
rubberband.addEventListener('mouseup', self.onmouseup)
rubberband.addEventListener('mousedown', self.onmousedown)
rubberband.addEventListener('mouseenter', self.onmouseenter)
rubberband.addEventListener('mouseleave', self.onmouseleave)
rubberband.addEventListener('keyup', self.onkeyup)
rubberband.addEventListener('keydown', self.onkeydown)
context = rubberband.getContext('2d')
context.strokeStyle = '#000000';
context.setLineDash([2, 2])
canvas_div.appendChild(rubberband)
div.appendChild(canvas_div)
bottom = document.createElement('div')
toolbar = self.toolbar.get_element()
bottom.appendChild(toolbar)
message = document.createElement('div')
message.id = self._id + 'message'
message.setAttribute('style', 'min-height: 1.5em')
bottom.appendChild(message)
div.appendChild(bottom)
self.draw()
def draw(self):
super().draw()
width, height = self.get_width_height()
canvas = self.get_element('canvas')
image_data = ImageData.new(
self.buffer_rgba(),
width, height);
ctx = canvas.getContext("2d");
ctx.putImageData(image_data, 0, 0);
self._idle_scheduled = False
def draw_idle(self):
if not self._idle_scheduled:
self._idle_scheduled = True
window.setTimeout(self.draw, 0)
def set_message(self, message):
message_display = self.get_element('message')
if message_display is not None:
message_display.textContent = message
def _convert_mouse_event(self, event):
width, height = self.get_width_height()
x = (event.offsetX * self._ratio)
y = ((height / self._ratio) - event.offsetY) * self._ratio
button = event.button + 1
if button == 3:
event.preventDefault()
event.stopPropagation()
if button == 2:
button = 3
return x, y, button
def onclick(self, event):
x, y, button = self._convert_mouse_event(event)
self.button_click_event(x, y, button, guiEvent=event)
def onmousemove(self, event):
x, y, button = self._convert_mouse_event(event)
self.motion_notify_event(x, y, guiEvent=event)
def onmouseup(self, event):
x, y, button = self._convert_mouse_event(event)
self.button_release_event(x, y, button, guiEvent=event)
def onmousedown(self, event):
x, y, button = self._convert_mouse_event(event)
self.button_press_event(x, y, button, guiEvent=event)
def onmouseenter(self, event):
window.addEventListener('contextmenu', ignore)
self.get_element('rubberband').focus()
self.enter_notify_event(guiEvent=event)
def onmouseleave(self, event):
self.get_element('rubberband').blur()
self.leave_notify_event(guiEvent=event)
def onscroll(self, event):
x, y, button = self._convert_mouse_event(event)
self.scroll_event(x, y, event.deltaX, guiEvent=event)
_cursor_map = {
0: 'pointer',
1: 'default',
2: 'crosshair',
3: 'move'
}
def set_cursor(self, cursor):
self.get_element('rubberband').style.cursor = self._cursor_map.get(cursor, 0)
def _convert_key_event(self, event):
code = int(event.which)
value = chr(code)
shift = event.shiftKey and code != 16
ctrl = event.ctrlKey and code != 17
alt = event.altKey and code != 18
# letter keys
if 65 <= code <= 90:
if not shift:
@ -279,11 +326,11 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
elif 96 <= code <= 105:
value = '%s' % (code - 96)
# keys with shift alternatives
elif code in _SHIFT_LUT and shift:
value = _SHIFT_LUT[code]
elif code in self._SHIFT_LUT and shift:
value = self._SHIFT_LUT[code]
shift = False
elif code in _LUT:
value = _LUT[code]
elif code in self._LUT:
value = self._LUT[code]
key = []
if shift:
@ -371,6 +418,7 @@ class NavigationToolbar2Wasm(backend_bases.NavigationToolbar2):
pass
def get_element(self):
# Creat the HTML content for the toolbar
div = document.createElement('span')
def add_spacer():
@ -405,6 +453,9 @@ class NavigationToolbar2Wasm(backend_bases.NavigationToolbar2):
self.download(format, FILE_TYPES[format])
def download(self, format, mimetype):
# Creates a temporary `a` element with a URL containing the image
# content, and then virtually clicks it. Kind of magical, but it
# works...
element = document.createElement('a')
data = io.BytesIO()
try: