Skip to Content
Technical Articles

Diving into Custom Widgets with SAP Analytics Cloud

Introduction

This post covers some of my learnings found when creating a dashboard that involved geospatial data based on custom regions specific to the dataset’s involved. I created a custom map widget to help deliver the showcase and learned many things along the way. In particular, it covers learnings in the following topics:

  • How to create a custom widget that can use external libraries
  • How to give access to SAC data source in custom widgets
  • Mapping geo-enriched data to a Feature Layer within the Custom Widget on the fly
  • Adding interaction from the geo-map to other SAC widgets

I hope others find it useful for their own SAC use case when using custom widgets.

End%20result%20of%20the%20dashboard.%20For%20more%20details%20on%20the%20use%20case%20displayed%20please%20reach%20out%20to%20me

The end result of the dashboard. For more details on the use case on display feel free to reach out to me

Motivation/Disclaimer

This post has come out of my own investigation as part of trying to build out a specific user experience for my own purposes. It is not to be taken as best or recommended practice. Instead, it should be viewed as another tool in your toolbox in how you can use SAC to achieve the desired user experience.

Creating a Custom Widget for an ESRI Map

I’m not going to cover all there is to know about custom widgets here as there are already many good blogs on getting started and you can also refer to the SAC help documents here: https://help.sap.com/viewer/0ac8c6754ff84605a4372468d002f2bf/2020.8/en-US

Instead, I will focus on a few key points I ran into along the way specific to creating this showcase. The full code for the widget can be found at the end of the post, I will refer to snippets of it where relevant.

Loading external libraries/scripts

The first part is how to load the JS required by our choice of map provider, ESRI. I chose to use ESRI as many geospatial features are already authored inside of ESRI and this makes it easier to work with when combining it with the geo-enriched business data.

As part of the standard setup for creating a custom widget, I have already defined my “Map” class separately.

Usually, to load a library you will add it to your page’s header, and it turns out the same behavior can be used to load one for a custom widget. So in order to use a library all that is needed is to move the custom element definition into the onload callback of the script element.

var script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://js.arcgis.com/4.15/";
script.onload = function(){
    customElements.define("com-sap-test-map", Map);
};
document.head.appendChild(script);

For setting up the map, I create a template as per the custom element API to hold the map. ESRI also needs a div with an ID for it to reference. The key part here however is the stylesheet. Custom Elements can reference external style sheets simply by using the standard link tag. Styles from this sheet will only affect the Custom Widget, so you don’t have to worry about how it may affect other elements.

let template = document.createElement("template");
template.innerHTML = `
	<link rel="stylesheet" href="https://js.arcgis.com/4.15/esri/themes/light/main.css">
		<style>
			#custom-map-view {
				width: 500px;
				height: 500px;
			}
		</style>
		<div id='custom-map-view'></div>
	`;

For the construction of the element, I recommend you do not use the shadow DOM from Web Components. The Shadow DOM concept is a great tool, and I hope to see more libraries adjust to support it in the future, but for now, ESRI does not support using out of the box and so it saves a lot of time and headaches by not using it.

constructor() {
	super(); 
	//this._shadowRoot = this.attachShadow({mode: "open"});
	this.appendChild(template.content.cloneNode(true));
	this._props = {};
	let that = this;

//SETUP CODE TRIMMED

The rest of the constructor is the standard ESRI map setup. I create a Map and a view for it, centered on Queensland, Australia where my data is based. I then set up a heatmap-style render for it to fit my scenario, plus associated fields, popup, and a spatial layer which I will describe in the next section.

Preparing Data

Any spatial data that is able to be loaded into an ESRI feature layer is supported. In my case, I needed to find a map of the regions that make up the local major hospital regions, and fortunately, the local government has a public access spatial catalog, which had this information in GeoJSON. I serve the file locally for my testing, but any fileserver could work for this. You could also create this spatial layer via any other feature layer type that ESRI supports.

that._spatialLayer = new GeoJSONLayer({
				url: "http://localhost:3000/Map/spatial.json",
				renderer: renderer,
				fields: fields,
				outFields: ["*"],
				popupTemplate: template
			 });

I set up a few fields for this:

  • In the GeoJSON there is an “HHS” field which is the one that will be used to map features to my dataset
  • I will update “Measure” later once I have my data loaded in SAC to contain the date for the feature
  • Because of the visualization type, I also need to set up a field to hold the “Max” value for the heatmap to reference.
const fields = [
		new Field({
		  name: "ObjectID",
		  alias: "ObjectID",
		  type: "oid"
		}), 
		new Field({
		  name: "HHS",
		  alias: "HHS",
		  type: "string"
		}), 
		new Field({
		  name: "Measure",
		  alias: "Measure",
		  type: "double"
		}), 
		new Field({
		  name: "Max",
		  alias: "Max",
		  type: "double"
		})
	];

The data in DWC has a few dimensions and measures defined, but the key one is the “HHS”. This dimension must contain matching data for a field within the feature layer to enable joining this business data with the spatial data later.

DWC%20Analytical%20Dataset%20used%20for%20this%20project

DWC Analytical Dataset used for this project

Exposing Data to Custom Widget

To expose data to a custom widget you pass the data to it via a method. Binding data directly to a custom widget is planned as part of the future direction which you can learn more about here: https://www.sapanalytics.cloud/wp-content/uploads/2020/08/ProductPlanQ32020_Public-1.pdf

First, I create a table and assign it a data source based on the DWC analytical dataset. I set up the dimensions and measures I need in my widget, specifically the HHS and a calculated measure representing coverage. I then change the visibility of the table to be hidden, as the users do not need to see this table.

Data%20Source%20Setup%20in%20SAC

Data Source Setup in SAC

Next, I add an event handler to the tables ‘onResultSetChanged’ method, as well as the Canvas’s ‘onInitialized’. These methods both do the same thing, they retrieve the data source from the table, and passes to the custom widget via the method defined on it.

Now any time the data changes in the table, for example, a filter gets applied, it will propagate to the widget. Any changes needed based on the changes to the data source can then be performed within the widget.

Inside the custom widget, the data source methods are all ‘async’ and so it’s easier to make the entire method ‘aysnc’ and use ‘await’ on any calls to it. The main call needed is ‘getResultSet’ as this fetch’s the current results of the data source based selected dimensions, measures, filters, etc.

async setDataSource(source) {
	this._dataSource = source;
	let resultSet = await source.getResultSet();
	const that = this;
	this._spatialLayer.queryFeatures().then(function(mapFeatures){
        //FEATURE MAPPING

Mapping to the Feature layer

Each entry in the result set is an object which contains the Measure value for a Dimension set. I query the spatial layer to get the map features loaded earlier. I then go over these features and see if there is a matching result (using the “HHS” field/dimension) from the data source. If there is, I retrieve the Measure value and add it to the list of edits to apply to the feature layer as part of a batch job. I also work out the max value which is needed for the heatmap renderer.

this._spatialLayer.queryFeatures().then(function(mapFeatures){
	const features = mapFeatures.features;
	const loc_id = that._props["locId"] || "";

	const edits = {
		updateFeatures: []
	}

	let max = 0;
	for(let feature of features) {
		let result = resultSet.find((result) => feature.attributes[loc_id] == result[loc_id].id);
		let value = result ? parseFloat(result["@MeasureDimension"].rawValue) : null;

		feature.attributes["Measure"] = value;
		max = value > max ? value : max;

		edits.updateFeatures.push(feature);
	}

	edits.updateFeatures.forEach((feature) => feature.attributes["Max"] = max);

	that._spatialLayer.applyEdits(edits).then((editResults) => {
		console.log(editResults);
	}).catch((error) => {					 
                console.log("===============================================");
		console.error("[ applyEdits ] FAILURE: ",error.code,error.name,error.message);
		console.log("error = ", error);
	})
});

Interaction from custom widget to SAC

To close the loop, a click handler is added to the map to allow user interaction. Then using the event framework that is part of the Web Components standard it is easy to send the selection region back to SAC. As each widget in an Analytic Application has its own data source, I then apply the selected region as a dimension filter to the widgets I want to filter with the selection.

that._view.on("click", function(event) {
	that._view.hitTest(event).then(function (response) {
		var regionGraphics = response.results.filter(function (result) {
			return result.graphic.layer === that._spatialLayer;
		});

		if (regionGraphics.length) {
			const event = new Event("onSelectRegion");
			that._currentSelection = regionGraphics[0].graphic.attributes['HHS']
			that.dispatchEvent(event);
		} else {
			const event = new Event("onDeselectRegion");
			that._currentSelection = null;
			that.dispatchEvent(event);
		}
	});
});

Event%20handler%20in%20SAC

The event handler in SAC

 

Complete widget code

Below is the complete code for the widget created for this demo. While this was created in a short period of time for a very specific purpose, it has served as a good base for other map-based custom widget work. I’ve since been thinking about how it could be made into a more generalized solution, for example by exposing properties on the custom widget for the GeoJSON URL and field parameters. If you are interested in this work feel free to reach out to me.

(function() { 
	let template = document.createElement("template");
	template.innerHTML = `
		<link rel="stylesheet" href="https://js.arcgis.com/4.15/esri/themes/light/main.css">
		<style>
			#custom-map-view {
				width: 500px;
				height: 500px;
			}
		</style>
		<div id='custom-map-view'></div>
	`;

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

			require([
				"esri/Map",
				"esri/views/MapView",
				"esri/layers/GeoJSONLayer",
				"esri/layers/support/Field",
				"esri/PopupTemplate"
			  ], function(Map, MapView, GeoJSONLayer, Field, PopupTemplate) {

				that._map = new Map({
					basemap: "topo-vector"
				});
			
				that._view = new MapView({
					container: "custom-map-view",
					map: that._map,
					center: [142.7028, -20.9176],
					zoom: 5
				});

				const template = new PopupTemplate({
					title: "{HHS} ventilators",
					content:
					  "{Measure} ventilators",
					fieldInfos: [
					  {
						fieldName: "Measure",
						format: {
						  digitSeparator: true,
						  places: 0
						}
					  },
					  {
						fieldName: "Max",
						format: {
						  digitSeparator: true,
						  places: 0
						}
					  }
					]
				  })

				const fields = [
					new Field({
					  name: "ObjectID",
					  alias: "ObjectID",
					  type: "oid"
					}), 
					new Field({
						name: "HHS",
						alias: "HHS",
						type: "string"
					}), 
					new Field({
					  name: "Measure",
					  alias: "Measure",
					  type: "double"
					}), 
					new Field({
					  name: "Max",
					  alias: "Max",
					  type: "double"
					})
				   ];

				let renderer = {
					type: "simple", 
					symbol: {
						type: "simple-fill", 
						outline: {
						color: "lightgray",
						width: 0.5
						}
					},
					label: "%",
					visualVariables: [
						{
						type: "color", 
						field: "Measure",
						normalizationField: "Max",
						stops: [
							{
							value: 0.5, 
							color: "#00FF00",
							label: "50%"
							},
							{
							value: 0.9,
							color: "#FF0000",
							label: "90%"
							}
						]
						}
					]
				};
				
				that._spatialLayer = new GeoJSONLayer({
					url: "http://localhost:3000/Map/spatial.json",
					renderer: renderer,
					fields: fields,
					outFields: ["*"],
					popupTemplate: template
				 });


				that._map.add(that._spatialLayer);
				
				that._view.on("click", function(event) {

					that._view.hitTest(event).then(function (response) {
						var regionGraphics = response.results.filter(function (result) {
							return result.graphic.layer === that._spatialLayer;
						});

						if (regionGraphics.length) {
							const event = new Event("onSelectRegion");
							that._currentSelection = regionGraphics[0].graphic.attributes['HHS']
							that.dispatchEvent(event);
						} else {
							const event = new Event("onDeselectRegion");
							that._currentSelection = null;
							that.dispatchEvent(event);
						}
					});
				});
			});

			/*
			fetch('http://localhost:3000/Map/spatial.json')
				.then(response => response.json())
				.then((data) => {
					console.log(data)
				});
			*/
		}

		getSelection() {
			return this._currentSelection;
		}

		async setDataSource(source) {
			this._dataSource = source;
			let googleResult = await fetch("https://cors-anywhere.herokuapp.com/https://www.rfs.nsw.gov.au/feeds/majorIncidents.json");
			let results = await googleResult.json();
			console.log(results);

			let resultSet = await source.getResultSet();
			const that = this;
			this._spatialLayer.queryFeatures().then(function(mapFeatures){
				const features = mapFeatures.features;

				const edits = {
					updateFeatures: []
				}

				const loc_id = that._props["locId"] || "";

				let max = 0;
				for(let feature of features) {
					let result = resultSet.find((result) => feature.attributes[loc_id] == result[loc_id].id);
					let value = result ? parseFloat(result["@MeasureDimension"].rawValue) : null;

					feature.attributes["Measure"] = value;
					max = value > max ? value : max;

					edits.updateFeatures.push(feature);
				}

				edits.updateFeatures.forEach((feature) => feature.attributes["Max"] = max);

				that._spatialLayer.applyEdits(edits)
					.then((editResults) => {
						console.log(editResults);
					})
					.catch((error) => {
						console.log("===============================================");
						console.error(
						  "[ applyEdits ] FAILURE: ",
						  error.code,
						  error.name,
						  error.message
						);
						console.log("error = ", error);
					})
			});
			
		}

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

		onCustomWidgetAfterUpdate(changedProperties) {
		}
	}

	let scriptSrc = "https://js.arcgis.com/4.15/"
	let onScriptLoaded = function() {
		customElements.define("com-sap-test-map", Map);
	}

	//SHARED FUNCTION: reuse between widgets
	//function(src, callback) {
	let customElementScripts = window.sessionStorage.getItem("customElementScripts") || [];

	let scriptStatus = customElementScripts.find(function(element) {
		return element.src == scriptSrc;
	});

	if (scriptStatus) {
		if(scriptStatus.status == "ready") {
			onScriptLoaded();
		} else {
			scriptStatus.callbacks.push(onScriptLoaded);
		}
	} else {

		let scriptObject = {
			"src": scriptSrc,
			"status": "loading",
			"callbacks": [onScriptLoaded]
		}

		customElementScripts.push(scriptObject);

		var script = document.createElement("script");
		script.type = "text/javascript";
		script.src = scriptSrc;
		script.onload = function(){
			scriptObject.status = "ready";
			scriptObject.callbacks.forEach((callbackFn) => callbackFn.call());
		};
		document.head.appendChild(script);
	}
	//} 
	//END SHARED FUNCTION
})();

Conclusion

This solution worked well for the showcase. It also served as a launch point in building more advanced use cases with the Analytic Application builder that I hope to bring to you in the future.

Overall, I hope this post has shown how you can do more advanced scenarios with SAC via the use of custom widgets. Let me know how you might use this knowledge when building out your use cases.

Be the first to leave a comment
You must be Logged on to comment or reply to a post.