All skills
secondsky avatar

/sap-sac-custom-widget

@4d87d99
by Eddiesecondsky/sap-skills456 stars
120

SAP Analytics Cloud (SAC) Custom Widget development. Use when building custom visualizations, extending SAC with Web Components, or creating Widget Add-Ons. Covers JSON metadata, JavaScript Web Components, lifecycle functions, data binding with feeds, styling/builder panels, property/event/method definitions, third-party library integration, hosting, security, performance, and debugging. Includes Widget Add-On feature (QRC Q4 2023+) and templates for widgets, charts, and KPI cards.

Use this Skill: https://skilld.dev/gh/secondsky/sap-skills/sap-sac-custom-widget

This session only. Nothing lands on disk.

referencesecharts-integration.md

≈5.9k tokens on demand. Your agent reads this file only when SKILL.md points to it.

ECharts Integration for SAP SAC Custom Widgets

Guide for integrating Apache ECharts library with SAP Analytics Cloud custom widgets.

Source: SAP Hands-on Guide


Table of Contents

  1. Overview
  2. Basic ECharts Widget
  3. Data-Bound ECharts Widget
  4. Common Chart Types
  5. Styling Panel for ECharts
  6. Performance Considerations

Overview

Apache ECharts is a powerful charting library that can be integrated into SAC custom widgets to create advanced visualizations not available in standard SAC charts.

ECharts CDN: https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js

For /widget-generate enterprise/offline packages and browser design runtime previews, do not assume this public CDN is available. Copy ECharts to a local vendor/echarts.min.js file or use a trusted hosted URL only after explicit deployment approval.

Key Benefits:

  • 20+ chart types (sankey, treemap, sunburst, radar, etc.)
  • Rich animation and interaction
  • Excellent performance with large datasets
  • Extensive customization options

Basic ECharts Widget

echarts-widget.json

{
  "id": "com.company.echartswidget",
  "version": "1.0.0",
  "name": "ECharts Widget",
  "description": "Custom chart using Apache ECharts",
  "vendor": "Company Name",
  "license": "MIT",
  "icon": "",
  "webcomponents": [
    {
      "kind": "main",
      "tag": "echarts-widget",
      "url": "/echarts-widget.js",
      "integrity": "",
      "ignoreIntegrity": true
    }
  ],
  "properties": {
    "title": {
      "type": "string",
      "default": "ECharts Demo"
    },
    "chartType": {
      "type": "string",
      "default": "bar"
    },
    "colorScheme": {
      "type": "string",
      "default": "#5470c6,#91cc75,#fac858,#ee6666,#73c0de"
    },
    "refreshToken": {
      "type": "integer",
      "default": 0
    }
  },
  "methods": {
    "refresh": {
      "description": "Refresh the chart",
      "body": "this.refreshToken = this.refreshToken + 1;"
    }
  },
  "events": {
    "onChartClick": {
      "description": "Fired when chart element is clicked"
    }
  }
}

echarts-widget.js

(function() {
  // General hand-written example: load ECharts library from a hosted URL.
  // Generated/offline packages should prefer vendor/echarts.min.js instead.
  const ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";

  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        width: 100%;
        height: 100%;
      }
      .chart-container {
        width: 100%;
        height: 100%;
      }
      .loading {
        display: flex;
        align-items: center;
        justify-content: center;
        height: 100%;
        color: #6a6d70;
        font-family: "72", Arial, sans-serif;
      }
    </style>
    <div class="chart-container" id="chart">
      <div class="loading">Loading ECharts...</div>
    </div>
  `;

  class EchartsWidget extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {
        title: "ECharts Demo",
        chartType: "bar",
        colorScheme: "#5470c6,#91cc75,#fac858,#ee6666,#73c0de"
      };
      this._chart = null;
      this._echartsLoaded = false;
    }

    connectedCallback() {
      this._loadEcharts();
    }

    _loadEcharts() {
      if (window.echarts) {
        this._echartsLoaded = true;
        this._initChart();
        return;
      }

      const script = document.createElement("script");
      script.src = ECHARTS_CDN;
      script.onload = () => {
        this._echartsLoaded = true;
        this._initChart();
      };
      script.onerror = () => {
        this._shadowRoot.getElementById("chart").innerHTML =
          '<div class="loading">Failed to load ECharts library</div>';
      };
      document.head.appendChild(script);
    }

    _initChart() {
      const container = this._shadowRoot.getElementById("chart");
      container.innerHTML = "";

      this._chart = echarts.init(container);

      // Handle click events
      this._chart.on("click", (params) => {
        this.dispatchEvent(new CustomEvent("onChartClick", {
          detail: {
            name: params.name,
            value: params.value,
            seriesName: params.seriesName
          }
        }));
      });

      this._render();
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      this._render();
    }

    onCustomWidgetResize() {
      if (this._chart) {
        this._chart.resize();
      }
    }

    onCustomWidgetDestroy() {
      if (this._chart) {
        this._chart.dispose();
        this._chart = null;
      }
    }

    _render() {
      if (!this._chart || !this._echartsLoaded) return;

      const colors = this._props.colorScheme.split(",").map(c => c.trim());

      // Demo data - replace with data binding data
      const option = this._getChartOption(colors);
      this._chart.setOption(option, true);
    }

    _getChartOption(colors) {
      const chartType = this._props.chartType;

      const baseOption = {
        title: {
          text: this._props.title,
          left: "center",
          textStyle: {
            fontFamily: '"72", Arial, sans-serif',
            fontSize: 16,
            fontWeight: 600
          }
        },
        color: colors,
        tooltip: {
          trigger: chartType === "pie" ? "item" : "axis"
        },
        grid: {
          left: "3%",
          right: "4%",
          bottom: "3%",
          containLabel: true
        }
      };

      // Demo data
      const categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
      const values = [150, 230, 224, 218, 135, 147];

      switch (chartType) {
        case "bar":
          return {
            ...baseOption,
            xAxis: { type: "category", data: categories },
            yAxis: { type: "value" },
            series: [{ type: "bar", data: values }]
          };

        case "line":
          return {
            ...baseOption,
            xAxis: { type: "category", data: categories },
            yAxis: { type: "value" },
            series: [{ type: "line", data: values, smooth: true }]
          };

        case "pie":
          return {
            ...baseOption,
            series: [{
              type: "pie",
              radius: "60%",
              data: categories.map((name, i) => ({ name, value: values[i] }))
            }]
          };

        default:
          return {
            ...baseOption,
            xAxis: { type: "category", data: categories },
            yAxis: { type: "value" },
            series: [{ type: "bar", data: values }]
          };
      }
    }

    _refresh() {
      this._render();
    }

    // Property getters/setters
    get title() { return this._props.title; }
    set title(v) { this._props.title = v; }
    get chartType() { return this._props.chartType; }
    set chartType(v) { this._props.chartType = v; }
    get colorScheme() { return this._props.colorScheme; }
    set colorScheme(v) { this._props.colorScheme = v; }
  }

  customElements.define("echarts-widget", EchartsWidget);
})();

Data-Bound ECharts Widget

Integrate with SAC data models via data binding.

echarts-databound.json

{
  "id": "com.company.echartsdatabound",
  "version": "1.0.0",
  "name": "ECharts Data-Bound",
  "description": "ECharts with SAC data binding",
  "vendor": "Company Name",
  "license": "MIT",
  "icon": "",
  "webcomponents": [
    {
      "kind": "main",
      "tag": "echarts-databound",
      "url": "/echarts-databound.js",
      "integrity": "",
      "ignoreIntegrity": true
    }
  ],
  "properties": {
    "title": { "type": "string", "default": "Data Chart" },
    "chartType": { "type": "string", "default": "bar" },
    "showLegend": { "type": "boolean", "default": true }
  },
  "methods": {},
  "events": {
    "onDataPointClick": {
      "description": "Fired when data point is clicked"
    }
  },
  "dataBindings": {
    "chartData": {
      "feeds": [
        { "id": "categories", "description": "Categories", "type": "dimension" },
        { "id": "values", "description": "Values", "type": "mainStructureMember" }
      ]
    }
  }
}

echarts-databound.js

(function() {
  const ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";

  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        width: 100%;
        height: 100%;
      }
      .chart-container {
        width: 100%;
        height: 100%;
      }
      .no-data {
        display: flex;
        align-items: center;
        justify-content: center;
        height: 100%;
        color: #6a6d70;
        font-family: "72", Arial, sans-serif;
        flex-direction: column;
        gap: 8px;
      }
      .no-data-icon {
        font-size: 32px;
        opacity: 0.5;
      }
    </style>
    <div class="chart-container" id="chart">
      <div class="no-data">
        <div class="no-data-icon">📊</div>
        <div>Add data binding to display chart</div>
      </div>
    </div>
  `;

  class EchartsDatabound extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {
        title: "Data Chart",
        chartType: "bar",
        showLegend: true
      };
      this._chart = null;
      this._echartsLoaded = false;
    }

    connectedCallback() {
      this._loadEcharts();
    }

    _loadEcharts() {
      if (window.echarts) {
        this._echartsLoaded = true;
        this._initChart();
        return;
      }

      const script = document.createElement("script");
      script.src = ECHARTS_CDN;
      script.onload = () => {
        this._echartsLoaded = true;
        this._initChart();
      };
      document.head.appendChild(script);
    }

    _initChart() {
      const container = this._shadowRoot.getElementById("chart");

      // Check for data
      if (!this._hasData()) {
        return;
      }

      container.innerHTML = "";
      this._chart = echarts.init(container);

      this._chart.on("click", (params) => {
        this.dispatchEvent(new CustomEvent("onDataPointClick", {
          detail: {
            category: params.name,
            value: params.value,
            dataIndex: params.dataIndex
          }
        }));
      });

      this._render();
    }

    _hasData() {
      return this.chartData &&
             this.chartData.data &&
             this.chartData.data.length > 0;
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      // Re-init if we now have data
      if (!this._chart && this._hasData() && this._echartsLoaded) {
        this._initChart();
      }
      this._render();
    }

    onCustomWidgetResize() {
      if (this._chart) {
        this._chart.resize();
      }
    }

    onCustomWidgetDestroy() {
      if (this._chart) {
        this._chart.dispose();
        this._chart = null;
      }
    }

    _render() {
      if (!this._chart || !this._echartsLoaded) return;

      if (!this._hasData()) {
        this._chart.clear();
        return;
      }

      const { categories, values } = this._parseDataBinding();
      const option = this._buildChartOption(categories, values);
      this._chart.setOption(option, true);
    }

    _parseDataBinding() {
      const data = this.chartData.data;
      const categories = [];
      const values = [];

      data.forEach(row => {
        // Get category (first dimension)
        if (row.categories_0) {
          categories.push(row.categories_0.label || row.categories_0.id);
        }

        // Get value (first measure)
        if (row.values_0) {
          values.push(row.values_0.raw || 0);
        }
      });

      return { categories, values };
    }

    _buildChartOption(categories, values) {
      const chartType = this._props.chartType;

      const option = {
        title: {
          text: this._props.title,
          left: "center",
          textStyle: {
            fontFamily: '"72", Arial, sans-serif',
            fontSize: 16
          }
        },
        tooltip: {
          trigger: chartType === "pie" ? "item" : "axis",
          formatter: chartType === "pie" ? "{b}: {c} ({d}%)" : undefined
        },
        legend: {
          show: this._props.showLegend,
          bottom: 0
        },
        color: ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de", "#3ba272"]
      };

      switch (chartType) {
        case "bar":
          return {
            ...option,
            xAxis: {
              type: "category",
              data: categories,
              axisLabel: { rotate: categories.length > 6 ? 45 : 0 }
            },
            yAxis: { type: "value" },
            grid: { left: "3%", right: "4%", bottom: "15%", containLabel: true },
            series: [{
              type: "bar",
              data: values,
              itemStyle: { borderRadius: [4, 4, 0, 0] }
            }]
          };

        case "line":
          return {
            ...option,
            xAxis: {
              type: "category",
              data: categories,
              boundaryGap: false
            },
            yAxis: { type: "value" },
            grid: { left: "3%", right: "4%", bottom: "15%", containLabel: true },
            series: [{
              type: "line",
              data: values,
              smooth: true,
              areaStyle: { opacity: 0.3 }
            }]
          };

        case "pie":
          return {
            ...option,
            series: [{
              type: "pie",
              radius: ["40%", "70%"],
              center: ["50%", "55%"],
              data: categories.map((name, i) => ({
                name,
                value: values[i]
              })),
              emphasis: {
                itemStyle: {
                  shadowBlur: 10,
                  shadowOffsetX: 0,
                  shadowColor: "rgba(0, 0, 0, 0.5)"
                }
              },
              label: {
                formatter: "{b}: {d}%"
              }
            }]
          };

        case "horizontal-bar":
          return {
            ...option,
            xAxis: { type: "value" },
            yAxis: {
              type: "category",
              data: categories
            },
            grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },
            series: [{
              type: "bar",
              data: values,
              itemStyle: { borderRadius: [0, 4, 4, 0] }
            }]
          };

        default:
          return option;
      }
    }

    // Property getters/setters
    get title() { return this._props.title; }
    set title(v) { this._props.title = v; }
    get chartType() { return this._props.chartType; }
    set chartType(v) { this._props.chartType = v; }
    get showLegend() { return this._props.showLegend; }
    set showLegend(v) { this._props.showLegend = v; }
  }

  customElements.define("echarts-databound", EchartsDatabound);
})();

Common Chart Types

Sankey Diagram

_buildSankeyOption(data) {
  return {
    series: [{
      type: "sankey",
      layout: "none",
      emphasis: { focus: "adjacency" },
      data: data.nodes, // [{name: "A"}, {name: "B"}]
      links: data.links // [{source: "A", target: "B", value: 100}]
    }]
  };
}

Treemap

_buildTreemapOption(data) {
  return {
    series: [{
      type: "treemap",
      data: data, // [{name: "A", value: 100, children: [...]}]
      levels: [
        { itemStyle: { borderWidth: 3 } },
        { itemStyle: { borderWidth: 1 } }
      ]
    }]
  };
}

Radar Chart

_buildRadarOption(categories, values) {
  return {
    radar: {
      indicator: categories.map(name => ({ name, max: Math.max(...values) * 1.2 }))
    },
    series: [{
      type: "radar",
      data: [{ value: values, name: "Values" }]
    }]
  };
}

Gauge Chart

_buildGaugeOption(value, target) {
  return {
    series: [{
      type: "gauge",
      progress: { show: true, width: 18 },
      axisLine: { lineStyle: { width: 18 } },
      axisTick: { show: false },
      splitLine: { length: 15, lineStyle: { width: 2 } },
      axisLabel: { distance: 25 },
      detail: {
        valueAnimation: true,
        formatter: "{value}%",
        fontSize: 24
      },
      data: [{ value: value, name: "Progress" }]
    }]
  };
}

Styling Panel for ECharts

echarts-styling.js

(function() {
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        font-family: "72", Arial, sans-serif;
        font-size: 12px;
      }
      .panel { padding: 12px; }
      .field { margin-bottom: 16px; }
      label {
        display: block;
        margin-bottom: 4px;
        font-weight: 500;
        color: #32363a;
      }
      select, input[type="text"] {
        width: 100%;
        padding: 8px;
        border: 1px solid #89919a;
        border-radius: 4px;
        font-size: 13px;
      }
      .checkbox-field {
        display: flex;
        align-items: center;
        gap: 8px;
      }
      .color-row {
        display: flex;
        gap: 4px;
        flex-wrap: wrap;
      }
      .color-input {
        width: 32px;
        height: 32px;
        padding: 2px;
        border: 1px solid #89919a;
        border-radius: 4px;
        cursor: pointer;
      }
    </style>
    <div class="panel">
      <div class="field">
        <label>Title</label>
        <input type="text" id="titleInput" />
      </div>
      <div class="field">
        <label>Chart Type</label>
        <select id="chartTypeSelect">
          <option value="bar">Bar Chart</option>
          <option value="line">Line Chart</option>
          <option value="pie">Pie Chart</option>
          <option value="horizontal-bar">Horizontal Bar</option>
        </select>
      </div>
      <div class="field checkbox-field">
        <input type="checkbox" id="legendCheckbox" />
        <label for="legendCheckbox">Show Legend</label>
      </div>
      <div class="field">
        <label>Colors</label>
        <div class="color-row">
          <input type="color" class="color-input" id="color1" value="#5470c6" />
          <input type="color" class="color-input" id="color2" value="#91cc75" />
          <input type="color" class="color-input" id="color3" value="#fac858" />
          <input type="color" class="color-input" id="color4" value="#ee6666" />
          <input type="color" class="color-input" id="color5" value="#73c0de" />
        </div>
      </div>
    </div>
  `;

  class EchartsStyling extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {};

      // Title
      this._shadowRoot.getElementById("titleInput").addEventListener("change", (e) => {
        this._fire({ title: e.target.value });
      });

      // Chart type
      this._shadowRoot.getElementById("chartTypeSelect").addEventListener("change", (e) => {
        this._fire({ chartType: e.target.value });
      });

      // Legend
      this._shadowRoot.getElementById("legendCheckbox").addEventListener("change", (e) => {
        this._fire({ showLegend: e.target.checked });
      });

      // Colors
      for (let i = 1; i <= 5; i++) {
        this._shadowRoot.getElementById(`color${i}`).addEventListener("input", () => {
          this._updateColors();
        });
      }
    }

    _fire(properties) {
      this.dispatchEvent(new CustomEvent("propertiesChanged", {
        detail: { properties }
      }));
    }

    _updateColors() {
      const colors = [];
      for (let i = 1; i <= 5; i++) {
        colors.push(this._shadowRoot.getElementById(`color${i}`).value);
      }
      this._fire({ colorScheme: colors.join(",") });
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      if (changedProperties.title !== undefined) {
        this._shadowRoot.getElementById("titleInput").value = changedProperties.title;
      }
      if (changedProperties.chartType !== undefined) {
        this._shadowRoot.getElementById("chartTypeSelect").value = changedProperties.chartType;
      }
      if (changedProperties.showLegend !== undefined) {
        this._shadowRoot.getElementById("legendCheckbox").checked = changedProperties.showLegend;
      }
      if (changedProperties.colorScheme !== undefined) {
        const colors = changedProperties.colorScheme.split(",");
        colors.forEach((color, i) => {
          const input = this._shadowRoot.getElementById(`color${i + 1}`);
          if (input) input.value = color.trim();
        });
      }
    }
  }

  customElements.define("echarts-styling", EchartsStyling);
})();

Performance Considerations

1. Lazy Load ECharts

Only load when widget is used:

_loadEcharts() {
  return new Promise((resolve, reject) => {
    if (window.echarts) {
      resolve(window.echarts);
      return;
    }

    const script = document.createElement("script");
    script.src = ECHARTS_CDN;
    script.onload = () => resolve(window.echarts);
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

2. Debounce Resize

onCustomWidgetResize() {
  if (this._resizeTimer) {
    clearTimeout(this._resizeTimer);
  }
  this._resizeTimer = setTimeout(() => {
    if (this._chart) {
      this._chart.resize();
    }
  }, 100);
}

3. Use notMerge for Large Updates

this._chart.setOption(option, { notMerge: true });

4. Limit Data Points

_parseDataBinding() {
  const data = this.chartData.data;
  const MAX_POINTS = 100;

  // Limit data for performance
  const limitedData = data.slice(0, MAX_POINTS);
  // ... parse data
}

5. Dispose on Destroy

onCustomWidgetDestroy() {
  if (this._chart) {
    this._chart.dispose();
    this._chart = null;
  }
}

ECharts Resources


Last Updated: 2025-11-22

Source: SKILL.md on GitHub

1 alert16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides a comprehensive environment and templates for developing SAP Analytics Cloud (SAC) Custom Widgets. It promotes robust security best practices, including subresource integrity (SRI), XSS prevention via input sanitization, and proper Shadow DOM encapsulation. It includes local tools for scaffold generation and iteration that operate on the user's machine without requiring external packages or services.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer6mo

    3/16 files flagged

  • ZeroLeaks5mo

    2 findings · Score: 80/100

Signed by skilld at 4d87d99. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated last month
What it can do
Reads files Runs commands Network
All 3 allowed tools
ReadBashWebFetch
Other metadata
metadata
{
  "maintainer": "Eduard Jiglau",
  "maintainer_email": "hello@sap-ai-skills.com",
  "website": "https://sap-ai-skills.com",
  "version": "2.4.1",
  "last_verified": "2026-06-12",
  "sac_version": "2026.8",
  "errors_prevented": "40+",
  "official_docs": [
    "https://help.sap.com/docs/SAP_ANALYTICS_CLOUD/0ac8c6754ff84605a4372468d002f2bf/75311f67527c41638ceb89af9cd8af3e.html",
    "https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf"
  ],
  "samples_repo": "https://github.com/SAP-samples/analytics-cloud-datasphere-community-content/tree/main/SAC_Custom_Widgets",
  "keywords": [
    "sap analytics cloud",
    "sac custom widget",
    "web component sac",
    "json metadata widget",
    "widget lifecycle functions",
    "onCustomWidgetBeforeUpdate",
    "onCustomWidgetAfterUpdate",
    "onCustomWidgetResize",
    "onCustomWidgetDestroy",
    "sac data binding",
    "dataBindings feeds",
    "styling panel widget",
    "builder panel widget",
    "sac echarts integration",
    "sac d3js integration",
    "third party library sac",
    "widget hosting sac",
    "integrity hash widget",
    "sha256 integrity",
    "widget security cors",
    "sac widget debugging",
    "sac analytics designer widget",
    "optimized story experience widget",
    "sac widget api",
    "widget add-on",
    "sac script api widget",
    "shadow dom web component",
    "sac tooltip customization",
    "plot area addon",
    "sac resource zip upload",
    "root relative widget url",
    "resource file upload",
    "builder focus collapse state",
    "self contained component js",
    "resource zip artifact naming",
    "chat download artifacts"
  ]
}

README badge

README badge for secondsky/sap-skills/sap-sac-custom-widget