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.

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.

 

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