Technology Blogs by SAP
Learn how to extend and personalize SAP applications. Follow the SAP technology blog for insights into SAP BTP, ABAP, SAP Analytics Cloud, SAP HANA, and more.
cancel
Showing results for 
Search instead for 
Did you mean: 
AndreaWang
Employee
Employee
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.

 
4 Comments
hannes2k
Explorer
0 Kudos
Hi Andrea,

i got issues running your example with and without Optimized View Mode (OVM) .

With OVM -> Message in Runtime ( Widget cannot be loaded )

Without OVM -> Error Message in Runtime ( custom widgets with data bindings that are not usable ).

 

Thanks and BR,

Hannes
AndreaWang
Employee
Employee
0 Kudos
Hi Hannes

 

Thanks for reading the blog

 

With OVM -> Please check if the main.js file is deployed properly.

The url of main.js is defined in index.json. Which is http://localhost:3000/pyodide/matplotlib/main.js as a sample.

And the styling.js, similar to the main.js

 

Without OVM -> Yes, This custom widget depends on the OVM.

All custom widgets with data binding are depend on OVM

 

Thanks,

Andrea

 

 
hannes2k
Explorer
0 Kudos
Hi Andrea,

thanks for the fast response. It works now.

I have somehow removed the databinding which is needed obviously and also noticed the example needs three measures.

Thanks for the great example.

Hannes
yuanga
Explorer
0 Kudos
Hi Andrea,

When adding the custom widget, I get the below error message.


 

I have checked the URL for the two web components and they are right, not sure what could be the cause?

 

Regards

Gaby