mirror of https://github.com/pyodide/pyodide.git
Add documentation to wasm_backend.py
This commit is contained in:
parent
663ff90695
commit
6319140bb6
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue