Matplotlib is a comprehensive library for creating static, animated, and interactive visualizations in Python.
You can extend the visualization capabilities in SAP Analytics Cloud with Matplotlib. In this blog post, I would like to share with you how to quickly add Matplotlib as custom widgets to your analytic application or optimized story.
The following video shows how Matplotlib looks like in SAP Analytics Cloud.
How to bring Matplotlib in SAP Analytics Cloud
SAP Analytics Cloud custom widget framework enables developers to create the web component. Matplotlib is a Python library, so the main idea is to introduce Pyodide to enable Python code execution in web component.
Here’re the detailed steps about how to implement a custom widget with Pyodide and Matplotlib:
1, Define Data Binding in custom widget JSON file
Here’s the sample code:
{
"dataBindings": {
"dataBinding": {
"feeds": [
{
"id": "dimensions",
"description": "Dimensions",
"type": "dimension"
},
{
"id": "measures",
"description": "Measures",
"type": "mainStructureMember"
}
]
}
}
}
For more details, refer to: Using Data Binding
2, Implement custom widget in main.js
The main.js file implements the following core workflows:
a, Read the data from SAP Analytics Cloud binding framework.
b, Pass the data to Pyodide so that the Python script can consume the data.
c, Call Pyodide to run the Python script.
d, Get the result of Python script and render the result as visualization.
Here’s the sample code:
// a, Read the data from SAP Analytics Cloud binding framework
const dataBinding = this.dataBinding
const { data, metadata } = dataBinding
// ...
// b, Pass the data to Pyodide so that the Python script could consume the data
window._pyodide_matplotlib_data = data.map(dp => {
// ...
})
// c, Call Pyodide to run the Python script
this._pyodide.runPython(this.py)
// d, Get the result of Python script and render the result as Visualization
this._pyplotfigure.src = this._pyodide.globals.get('img_str')
3, Use the custom widget in SAP Analytics Cloud.
After uploading the custom widget to SAP Analytics Cloud and inserting it to your analytic application or optimized story, to render the visualization:
a, In the Builder panel of the custom widget, bind it to a data source.
b, In the Styling panel, write the Python script, which is stored as a string variable in the custom widget. (this.py in the example above)
c, Apply the data binding and the Python script.
Then, the visualization is rendered in the application or story.
Full source code of this example
index.json
{
"eula": "",
"vendor": "SAP",
"license": "",
"id": "com.sap.sac.sample.pyodide.matplotlib",
"version": "1.0.0",
"supportsMobile": true,
"name": "Pyodide Matplotlib",
"newInstancePrefix": "PyodideMatplotlib",
"description": "A sample custom widget based on Pyodide and Matplotlib",
"webcomponents": [
{
"kind": "main",
"tag": "com-sap-sample-pyodide-matplotlib",
"url": "http://localhost:3000/pyodide/matplotlib/main.js",
"integrity": "",
"ignoreIntegrity": true
},
{
"kind": "styling",
"tag": "com-sap-sample-pyodide-matplotlib-styling",
"url": "http://localhost:3000/pyodide/matplotlib/styling.js",
"integrity": "",
"ignoreIntegrity": true
}
],
"properties": {
"width": {
"type": "integer",
"default": 600
},
"height": {
"type": "integer",
"default": 420
},
"py": {
"type": "string"
}
},
"methods": {},
"events": {},
"dataBindings": {
"dataBinding": {
"feeds": [
{
"id": "dimensions",
"description": "Dimensions",
"type": "dimension"
},
{
"id": "measures",
"description": "Measures",
"type": "mainStructureMember"
}
]
}
}
}
main.js
var getScriptPromisify = (src) => {
return new Promise(resolve => {
$.getScript(src, resolve)
})
}
const parseMetadata = metadata => {
const { dimensions: dimensionsMap, mainStructureMembers: measuresMap } = metadata
const dimensions = []
for (const key in dimensionsMap) {
const dimension = dimensionsMap[key]
dimensions.push({ key, ...dimension })
}
const measures = []
for (const key in measuresMap) {
const measure = measuresMap[key]
measures.push({ key, ...measure })
}
return { dimensions, measures, dimensionsMap, measuresMap }
}
(function () {
const template = document.createElement('template')
template.innerHTML = `
<style>
</style>
<div id="root" style="width: 100%; height: 100%; text-align: center;">
<img id="pyplotfigure"/>
</div>
`
class Main extends HTMLElement {
constructor () {
super()
this._shadowRoot = this.attachShadow({ mode: 'open' })
this._shadowRoot.appendChild(template.content.cloneNode(true))
this._root = this._shadowRoot.getElementById('root')
this._pyplotfigure = this._shadowRoot.getElementById('pyplotfigure')
this._props = {}
this._pyodide = null
this.bootstrap()
}
async onCustomWidgetAfterUpdate (changedProps) {
this.render()
}
onCustomWidgetResize (width, height) {
this.render()
}
async bootstrap () {
// https://cdnjs.cloudflare.com/ajax/libs/pyodide/0.21.3/pyodide.js
// https://cdn.staticfile.org/pyodide/0.21.3/pyodide.js
await getScriptPromisify('https://cdn.jsdelivr.net/pyodide/v0.21.3/full/pyodide.js')
const pyodide = await loadPyodide()
await pyodide.loadPackage('matplotlib')
this._pyodide = pyodide
this.render()
}
async render () {
this.dispose()
if (!this._pyodide) { return }
if (!this.py) { return }
const dataBinding = this.dataBinding
if (!dataBinding || dataBinding.state !== 'success') { return }
const { data, metadata } = dataBinding
const { dimensions, measures } = parseMetadata(metadata)
if (dimensions.length !== 1) { return }
if (measures.length !== 3) { return }
const [d] = dimensions
const [m0, m1, m2] = measures
const million = 1000 * 1000
// window._pyodide_matplotlib_data = [[11, 12, 15], [13, 6, 20], [10, 8, 12], [12, 15, 8]]
window._pyodide_matplotlib_data = data.map(dp => {
return [
dp[m0.key].raw / million,
dp[m1.key].raw / million,
dp[m2.key].raw / million
]
})
window._pyodide_matplotlib_title = `${[m0.label, m1.label, m2.label].join(', ')} per ${d.description}`
// https://pyodide.org/en/stable/usage/type-conversions.html
this._pyodide.runPython(this.py)
this._pyplotfigure.src = this._pyodide.globals.get('img_str')
this._pyplotfigure.style.width = '100%'
this._pyplotfigure.style.height = '100%'
}
dispose () {
this._pyplotfigure.src = ''
this._pyplotfigure.style.width = ''
this._pyplotfigure.style.height = ''
}
}
customElements.define('com-sap-sample-pyodide-matplotlib', Main)
})()
styling.js
const template = document.createElement('template')
template.innerHTML = `
<style>
#root div {
margin: 0.5rem;
}
#root .title {
font-weight: bold;
}
#root #code {
width: 100%;
height: 480px;
}
</style>
<div id="root" style="width: 100%; height: 100%;">
<div class="title">Python code</div>
<textarea id="code"></textarea>
</div>
<div>
<button id="button">Apply</button>
</div>
`
const PY_DEFAULT = `from matplotlib import pyplot as plt
import numpy as np
import io, base64
from js import _pyodide_matplotlib_data, _pyodide_matplotlib_title
SAC_DATA = _pyodide_matplotlib_data.to_py()
SAC_TITLE = _pyodide_matplotlib_title
# Generate data points from SAC_DATA
x = []
y = []
scale = []
for row in SAC_DATA:
x.append(row[0])
y.append(row[1])
scale.append(row[2])
# Map each onto a scatterplot we'll create with Matplotlib
fig, ax = plt.subplots()
ax.scatter(x=x, y=y, c=scale, s=np.abs(scale)*200)
ax.set(title=SAC_TITLE)
# plt.show()
buf = io.BytesIO()
fig.savefig(buf, format='png')
buf.seek(0)
img_str = 'data:image/png;base64,' + base64.b64encode(buf.read()).decode('UTF-8')`
class Styling extends HTMLElement {
constructor () {
super()
this._shadowRoot = this.attachShadow({ mode: 'open' })
this._shadowRoot.appendChild(template.content.cloneNode(true))
this._root = this._shadowRoot.getElementById('root')
this._code = this._shadowRoot.getElementById('code')
this._code.value = PY_DEFAULT
this._button = this._shadowRoot.getElementById('button')
this._button.addEventListener('click', () => {
const py = this._code.value
this.dispatchEvent(new CustomEvent('propertiesChanged', { detail: { properties: { py } } }))
})
}
// ------------------
// LifecycleCallbacks
// ------------------
async onCustomWidgetBeforeUpdate (changedProps) {
}
async onCustomWidgetAfterUpdate (changedProps) {
if (changedProps.py) {
this._code.value = changedProps.py
}
}
async onCustomWidgetResize (width, height) {
}
async onCustomWidgetDestroy () {
this.dispose()
}
// ------------------
//
// ------------------
dispose () {
}
}
customElements.define('com-sap-sample-pyodide-matplotlib-styling', Styling)
This concludes the blog. Feel free to share your thoughts below.