Skip to Content
Technical Articles
Author's profile photo Andrea Wang

Extending SAP Analytics Cloud’s Visualization Capability with Matplotlib (Python)

Matplotlib is a comprehensive library for creating static, animated, and interactive visualizations in Python.

Bringing Matplotlib in SAP Analytics Cloud will extend its visualization capability. In this blog post, I would like to share with you how to quickly add Matplotlib as custom widgets into SAP Analytics Cloud.

Here is the demo shows how Matplotlib looks like in SAP Analytics Cloud.

 

How to bring Matplotlib in SAP Analytics Cloud

SAP Analytics Cloud Custom widget framework to enable developer to create the web component. And the Matplotlib is a Python lib, so the major idea is introduce the Pyodide to enable Python code execution in web component.

About how to implement a custom widget with Pyodide and Matplotlib. Here is the detail steps:

1, Define Data Binding in custom widget json file

An example as below:

{
  "dataBindings": {
    "dataBinding": {
      "feeds": [
        {
          "id": "dimensions",
          "description": "Dimensions",
          "type": "dimension"
        },
        {
          "id": "measures",
          "description": "Measures",
          "type": "mainStructureMember"
        }
      ]
    }
  }
}

More details could refer to: Using Data Binding

2, Implement custom widget in main.js

The main.js file implement the core workflows:

a, Read the data from SAP Analytics Cloud binding framework

b, Pass the data to Pyodide so that the Python script could consume the data

c, Call Pyodide to run the Python script

d, Get the result of Python script and render the result as Visualization

// 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.

a, Bind data using the Building Panel of SAP Analytics Cloud.

b, Write the Python Script using the Styling Panel of SAP Analytics Cloud. The Python Script would be stored as a string varible in Custom Widget. (this.py in above sample)

c, Apply the Data Binding and the Python Script, The Visualization would be rendered on the Page by Custom Widget.

 

Full source code of this sample

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 is the end of the blog. Your comments and suggestions are welcome.

 

Assigned Tags

      3 Comments
      You must be Logged on to comment or reply to a post.
      Author's profile photo Hannes Kroener
      Hannes Kroener

      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

      Author's profile photo Andrea Wang
      Andrea Wang
      Blog Post Author

      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

       

       

      Author's profile photo Hannes Kroener
      Hannes Kroener

      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