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 base64
import io import io
import math import math
@ -6,75 +20,11 @@ from matplotlib.backends import backend_agg
from matplotlib.backend_bases import _Backend from matplotlib.backend_bases import _Backend
from matplotlib import backend_bases, _png from matplotlib import backend_bases, _png
from js import iodide
from js import document from js import document
from js import window from js import window
from js import ImageData from js import ImageData
# http://www.cambiaresearch.com/articles/15/javascript-char-codes-key-codes
_SHIFT_LUT = {
59: ':',
61: '+',
173: '_',
186: ':',
187: '+',
188: '<',
189: '_',
190: '>',
191: '?',
192: '~',
219: '{',
220: '|',
221: '}',
222: '"'
}
_LUT = {
8: 'backspace',
9: 'tab',
13: 'enter',
16: 'shift',
17: 'control',
18: 'alt',
19: 'pause',
20: 'caps',
27: 'escape',
32: ' ',
33: 'pageup',
34: 'pagedown',
35: 'end',
36: 'home',
37: 'left',
38: 'up',
39: 'right',
40: 'down',
45: 'insert',
46: 'delete',
91: 'super',
92: 'super',
93: 'select',
106: '*',
107: '+',
109: '-',
110: '.',
111: '/',
144: 'num_lock',
145: 'scroll_lock',
186: ':',
187: '=',
188: ',',
189: '-',
190: '.',
191: '/',
192: '`',
219: '[',
220: '\\',
221: ']',
222: "'"
}
class FigureCanvasWasm(backend_agg.FigureCanvasAgg): class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
supports_blit = False supports_blit = False
@ -87,12 +37,21 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
self._ratio = 1 self._ratio = 1
def get_element(self, name): 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 # TODO: Should we store a reference here instead of always looking it
# up? I'm a little concerned about weird Python/JS # up? I'm a little concerned about weird Python/JS
# cross-memory-management issues... # cross-memory-management issues...
return document.getElementById(self._id + name) return document.getElementById(self._id + name)
def get_dpi_ratio(self, context): 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 = ( backing_store = (
context.backingStorePixelRatio or context.backingStorePixelRatio or
context.webkitBackingStorePixel or context.webkitBackingStorePixel or
@ -104,36 +63,52 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
) )
return (window.devicePixelRatio or 1) / backing_store 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): 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('') existing = self.get_element('')
if existing is not None: if existing is not None:
self.draw_idle() self.draw_idle()
existing.scrollIntoView() existing.scrollIntoView()
return return
# Disable the right-click context menu.
# Doesn't work in all browsers.
def ignore(event): def ignore(event):
event.preventDefault() event.preventDefault()
return False return False
window.addEventListener('contextmenu', ignore) window.addEventListener('contextmenu', ignore)
# Create the main canvas and determine the physical to logical pixel
# ratio
canvas = document.createElement('canvas') canvas = document.createElement('canvas')
context = canvas.getContext('2d') context = canvas.getContext('2d')
self._ratio = self.get_dpi_ratio(context) self._ratio = self.get_dpi_ratio(context)
if self._ratio != 1: if self._ratio != 1:
self.figure.dpi *= self._ratio self.figure.dpi *= self._ratio
renderer = self.get_renderer()
width, height = self.get_width_height() width, height = self.get_width_height()
div = iodide.output.element('div') div = self.create_root_element()
div.setAttribute('style', 'width: {}px'.format(width / self._ratio)) div.setAttribute('style', 'width: {}px'.format(width / self._ratio))
div.id = self._id div.id = self._id
# The top bar
top = document.createElement('div') top = document.createElement('div')
top.id = self._id + 'top' top.id = self._id + 'top'
top.setAttribute('style', 'font-weight: bold; text-align: center') top.setAttribute('style', 'font-weight: bold; text-align: center')
top.textContent = self._title top.textContent = self._title
div.appendChild(top) 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 = document.createElement('div')
canvas_div.setAttribute('style', 'position: relative') canvas_div.setAttribute('style', 'position: relative')
@ -156,7 +131,10 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
'width: {}px; height: {}px'.format( 'width: {}px; height: {}px'.format(
width / self._ratio, height / self._ratio) width / self._ratio, height / self._ratio)
) )
# Canvas must have a "tabindex" attr in order to receive keyboard events
rubberband.setAttribute('tabindex', '0') 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('click', self.onclick)
rubberband.addEventListener('mousemove', self.onmousemove) rubberband.addEventListener('mousemove', self.onmousemove)
rubberband.addEventListener('mouseup', self.onmouseup) rubberband.addEventListener('mouseup', self.onmouseup)
@ -172,6 +150,7 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
div.appendChild(canvas_div) div.appendChild(canvas_div)
# The bottom bar, with toolbar and message display
bottom = document.createElement('div') bottom = document.createElement('div')
toolbar = self.toolbar.get_element() toolbar = self.toolbar.get_element()
bottom.appendChild(toolbar) bottom.appendChild(toolbar)
@ -184,7 +163,9 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
self.draw() self.draw()
def draw(self): def draw(self):
# Render the figure using Agg
super().draw() super().draw()
# Copy the image buffer to the canvas
width, height = self.get_width_height() width, height = self.get_width_height()
canvas = self.get_element('canvas') canvas = self.get_element('canvas')
image_data = ImageData.new( image_data = ImageData.new(
@ -209,6 +190,7 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
x = (event.offsetX * self._ratio) x = (event.offsetX * self._ratio)
y = ((height / self._ratio) - event.offsetY) * self._ratio y = ((height / self._ratio) - event.offsetY) * self._ratio
button = event.button + 1 button = event.button + 1
# Disable the right-click context menu in some browsers
if button == 3: if button == 3:
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@ -234,10 +216,12 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
def onmouseenter(self, event): def onmouseenter(self, event):
window.addEventListener('contextmenu', ignore) window.addEventListener('contextmenu', ignore)
# When the mouse is over the figure, get keyboard focus
self.get_element('rubberband').focus() self.get_element('rubberband').focus()
self.enter_notify_event(guiEvent=event) self.enter_notify_event(guiEvent=event)
def onmouseleave(self, event): def onmouseleave(self, event):
# When the mouse leaves the figure, drop keyboard focus
self.get_element('rubberband').blur() self.get_element('rubberband').blur()
self.leave_notify_event(guiEvent=event) self.leave_notify_event(guiEvent=event)
@ -255,12 +239,75 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
def set_cursor(self, cursor): def set_cursor(self, cursor):
self.get_element('rubberband').style.cursor = self._cursor_map.get(cursor, 0) 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: ':',
61: '+',
173: '_',
186: ':',
187: '+',
188: '<',
189: '_',
190: '>',
191: '?',
192: '~',
219: '{',
220: '|',
221: '}',
222: '"'
}
_LUT = {
8: 'backspace',
9: 'tab',
13: 'enter',
16: 'shift',
17: 'control',
18: 'alt',
19: 'pause',
20: 'caps',
27: 'escape',
32: ' ',
33: 'pageup',
34: 'pagedown',
35: 'end',
36: 'home',
37: 'left',
38: 'up',
39: 'right',
40: 'down',
45: 'insert',
46: 'delete',
91: 'super',
92: 'super',
93: 'select',
106: '*',
107: '+',
109: '-',
110: '.',
111: '/',
144: 'num_lock',
145: 'scroll_lock',
186: ':',
187: '=',
188: ',',
189: '-',
190: '.',
191: '/',
192: '`',
219: '[',
220: '\\',
221: ']',
222: "'"
}
def _convert_key_event(self, event): def _convert_key_event(self, event):
code = int(event.which) code = int(event.which)
value = chr(code) value = chr(code)
shift = event.shiftKey and code != 16 shift = event.shiftKey and code != 16
ctrl = event.ctrlKey and code != 17 ctrl = event.ctrlKey and code != 17
alt = event.altKey and code != 18 alt = event.altKey and code != 18
# letter keys # letter keys
if 65 <= code <= 90: if 65 <= code <= 90:
if not shift: if not shift:
@ -279,11 +326,11 @@ class FigureCanvasWasm(backend_agg.FigureCanvasAgg):
elif 96 <= code <= 105: elif 96 <= code <= 105:
value = '%s' % (code - 96) value = '%s' % (code - 96)
# keys with shift alternatives # keys with shift alternatives
elif code in _SHIFT_LUT and shift: elif code in self._SHIFT_LUT and shift:
value = _SHIFT_LUT[code] value = self._SHIFT_LUT[code]
shift = False shift = False
elif code in _LUT: elif code in self._LUT:
value = _LUT[code] value = self._LUT[code]
key = [] key = []
if shift: if shift:
@ -371,6 +418,7 @@ class NavigationToolbar2Wasm(backend_bases.NavigationToolbar2):
pass pass
def get_element(self): def get_element(self):
# Creat the HTML content for the toolbar
div = document.createElement('span') div = document.createElement('span')
def add_spacer(): def add_spacer():
@ -405,6 +453,9 @@ class NavigationToolbar2Wasm(backend_bases.NavigationToolbar2):
self.download(format, FILE_TYPES[format]) self.download(format, FILE_TYPES[format])
def download(self, format, mimetype): 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') element = document.createElement('a')
data = io.BytesIO() data = io.BytesIO()
try: try: