Technology Blogs by Members
Explore a vibrant mix of technical expertise, industry insights, and tech buzz in member blogs covering SAP products, technology, and events. Get in the mix!
cancel
Showing results for 
Search instead for 
Did you mean: 
Masoom
Explorer

Introduction


Couple of weeks back my manager asked me to build a POC by integrating D3 maps/charts, he showed D3's website and asked me to explore, he asked me take any one example and build an app, I did some google search on it and found most relevant videos in YouTube which is listed below:

https://www.youtube.com/watch?v=qMJHuz9DHPc

https://www.youtube.com/watch?v=cY-WCWC1qxE

Thanks to Maximilian Lenkeit who has explained nicely all the concepts on how to integrate D3 in UI5 by using a custom control, he also explained on how to take codes piece by piece from D3 and put it into the UI5 custom control and so on. He has taken example of D3's bubble chart and built a Fiori app.

I have followed the same approach here in my example to built Walmart's growth like map, however it was not that straight forward like the bubble chart as it is having some kind of animation, however I have given a try, initially I was only able to display the map with the scrubber, but later after some struggle I was able to figure out on how I can make it animated, had to do a little bit tricks on the D3  code as well.

We are going to build a free-style Fiori app which uses D3.js libraries to build a growth map like Walmart's growth please refer to this link for code on D3:

https://observablehq.com/@d3/walmarts-growth?intent=fork

Environment:



  • SAP Web IDE Full-Stack


Final Output:



 

Technical Code:


I am not following here step by step approach on how to create app, view, controller etc., because it will make the blog long, so summarizing technical details as below:

App technical name is : "ZGROWTHMAP" with below folder structure :


Where,

model(folder) contains data;

  • "usatlas.json" : It contains map data; US map is designed with the help of this file

  • "WalmartGrowthData.json" : It contains growth data, technically it will have three columns Latitude, Longitude & Date, with the help of this file Circle with black or blue color is mapped to specific geo co-ordinates


Other than standard folder structure we have created two additional folders below:

  • "controls" - this folder contains custom control JavaScript coding for implementing Walmart's Growth like Map using d3 visualization

  • "thirdparty" - this folder contains the JavaScript libraries d3.js & topojson.js


Note: data and libraries you can get from the link given below:

https://observablehq.com/@d3/walmarts-growth?intent=fork

 

Main View:
<mvc:View 
controllerName="ns.ZGROWTHMAP.controller.Main"
xmlns:mvc="sap.ui.core.mvc"
displayBlock="true"
xmlns="sap.m"
xmlns:core="sap.ui.core"
xmlns:controls="ns.ZGROWTHMAP.controls"
xmlns:base="sap.ui.base"
xmlns:html="http://www.w3.org/1999/xhtml">

<Page id="page" title="{i18n>title}">

<!--Scrubber-->
<html:form id="scrubberfrm" style="font: 12px var(--sans-serif); font-variant-numeric: tabular-nums; display: flex; height: 33px; align-items: center;">
<html:button name="b" type="button" style="margin-right: 0.4em; width: 5em;"></html:button>
<html:label style="display: flex; align-items: center;">
<html:input name="i" type="range" min='0' max='0' value='0' step='1' style="width: 180px;"></html:input>
<html:output name="o" style="margin-left: 0.4em;"></html:output>
</html:label>
</html:form>

<!--Map-->
<controls:D3GrowthViz data="{/GrowthData}">
<controls:data>
<base:ManagedObject />
</controls:data>
</controls:D3GrowthViz>
</Page>

</mvc:View>

 

Main Controller:
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/model/json/JSONModel",
"jquery.sap.global"
], function (Controller, JSONModel, jQuery) {
"use strict";

return Controller.extend("ns.ZGROWTHMAP.controller.Main", {

onInit: function () {
this.getGemometryData();
this.getGrowthData();
},

getGemometryData: function () {

//Read data from JSON file
var oModel = new JSONModel();
var sPath = jQuery.sap.getModulePath("ns.ZGROWTHMAP", "/model/usatlas.json");

jQuery.ajax({
url: sPath,
dataType: "json",
async: false, // Synchronous loading for simplicity (not recommended in production)
success: function (oData) {
oModel.setData(oData);
},
error: function (err) {
console.log(err);
},
});

this.getView().setModel(oModel, "USAtlasData"); // Set the JSON data model

},
getGrowthData: function () {

//Read data from JSON file
var oModel = new JSONModel();
var sPath = jQuery.sap.getModulePath("ns.ZGROWTHMAP", "/model/WalmartGrowthData.json");

jQuery.ajax({
url: sPath,
dataType: "json",
async: false, // Synchronous loading for simplicity (not recommended in production)
success: function (oData) {
oModel.setData(oData);
},
error: function (err) {
console.log(err);
},
});

this.getView().setModel(oModel, "GrowthData"); // Set the JSON data model
}

});
});

 

Custom control:
sap.ui.define([
"sap/ui/core/Control",
"sap/ui/core/HTML",
"sap/ui/core/ResizeHandler",
"ns/ZGROWTHMAP/thirdparty/d3",
"ns/ZGROWTHMAP/thirdparty/topojson"

], function (Control, HTML, ResizeHandler) {
"use strict";

var dot;
let previousDate = -Infinity;

return Control.extend("ns.ZGROWTHMAP.controls.D3GrowthViz", {

metadata: {
aggregations: {
_html: {
type: "sap.ui.core.HTML",
multiple: false,
visibility: "hidden"
},
data: {
type: "sap.ui.base.ManagedObject"
}
}
},

init: function () {
this._sContainerId = this.getId() + "--container"
this.setAggregation("_html", new HTML({
content: "<svg id='" + this._sContainerId + "'></svg>"
}));
},

exit: function () {
ResizeHandler.deregister(this._sResizeHandlerId);
},

renderer: {
apiVersion: 2,
render: function (oRm, oControl) {
oRm.openStart('div', oControl);
oRm.openEnd();
oRm.openStart('p').openEnd();
oRm.close('p');
oRm.renderControl(oControl.getAggregation('_html'));
oRm.close('div');
}
},

_onResize: function () {
this._renderViz();
},

onBeforeRendering: function () {
ResizeHandler.deregister(this._sResizeHandlerId);
},

onAfterRendering: function () {
this._sResizeHandlerId = ResizeHandler.register(this, this._onResize.bind(this));
this._renderViz();
},

_parseDate: function (d3) {
return d3.utcParse("%m/%d/%Y");
//return d3.utcParse("%Y-%m-%dT%H:%M:%S.%LZ");
},

_projection: function (d3) {
return d3.geoAlbersUsa().scale(1280).translate([480, 300]);
},

_renderViz: function () {

const height = 620;
const width = this.$().width();
const svg = d3.select('#' + this._sContainerId);
svg.attr("height", height).attr("width", width);
svg.attr("viewBox", [0, 0, width, height]);

/** The following snippet is based on the Walmart's growth of Mike Bostock
https://observablehq.com/@d3/walmarts-growth?intent=fork*/
const us = this.getModel("USAtlasData").getData(); //get Geometry data

us.objects.lower48 = {
type: "GeometryCollection",
geometries: us.objects.states.geometries.filter(d => d.id !== "02" && d.id !== "15")
};

svg.append("path")
.datum(topojson.merge(us, us.objects.lower48.geometries))
.attr("fill", "#ddd")
.attr("d", d3.geoPath());

svg.append("path")
.datum(topojson.mesh(us, us.objects.lower48, (a, b) => a !== b))
.attr("fill", "none")
.attr("stroke", "white")
.attr("stroke-linejoin", "round")
.attr("d", d3.geoPath());

const g = svg.append("g")
.attr("fill", "none")
.attr("stroke", "black");

const growthdata = this.getModel("GrowthData").getData(); //get growth data
const projection = that._projection(d3);
const parseDate = that._parseDate(d3);

const data = growthdata.map(d => {
const p = projection(d);
p.date = parseDate(d.date);
return p;
})
.sort((a, b) => a.date - b.date);

// const
dot = g.selectAll("circle")
.data(data)
.join("circle")
.attr("transform", d => `translate(${d})`);

svg.append("circle")
.attr("fill", "blue")
.attr("transform", `translate(${data[0]})`)
.attr("r", 3);

var dates = d3.utcWeek.every(2).range(...d3.extent(data, d => d.date));
var lastdate = d3.extent(data, d => d.date)[1];
dates.push(lastdate);

that.scrubber(dates, {
format: d3.utcFormat("%Y %b %-d"),
loop: false,
autoplay: false
});
},

updatechart: function (date) {
dot // enter
.filter(d => d.date > previousDate && d.date <= date)
.transition().attr("r", 3);
dot // exit
.filter(d => d.date <= previousDate && d.date > date)
.transition().attr("r", 0);
previousDate = date;
},

scrubber: function (values, {
format = value => value,
initial = 0,
direction = 1,
delay = null,
autoplay = true,
loop = true,
loopDelay = null,
alternate = false
} = {}) {

/** this logic is based on Scrubber by Mike
Bostock https://observablehq.com/@mbostock/scrubber */

const form = document.getElementById("container-ZGROWTHMAP---Main--scrubberfrm");

form.i.max = values.length - 1;
form.i.value = initial;
values = Array.from(values);

let frame = null;
let timer = null;
let interval = null;

function start() {
form.b.textContent = "Pause";
if (delay === null) frame = requestAnimationFrame(tick);
else interval = setInterval(tick, delay);
}

function stop() {
form.b.textContent = "Play";
if (frame !== null) cancelAnimationFrame(frame), frame = null;
if (timer !== null) clearTimeout(timer), timer = null;
if (interval !== null) clearInterval(interval), interval = null;
}

function running() {
return frame !== null || timer !== null || interval !== null;
}

function tick() {
if (form.i.valueAsNumber === (direction > 0 ? values.length - 1 : direction < 0 ? 0 : NaN)) {
if (!loop) return stop();
if (alternate) direction = -direction;
if (loopDelay !== null) {
if (frame !== null) cancelAnimationFrame(frame), frame = null;
if (interval !== null) clearInterval(interval), interval = null;
timer = setTimeout(() => (step(), start()), loopDelay);
return;
}
}
if (delay === null) frame = requestAnimationFrame(tick);
step();
}

function step() {
form.i.valueAsNumber = (form.i.valueAsNumber + direction + values.length) % values.length;
form.i.dispatchEvent(new CustomEvent("input", {
bubbles: true
}));
}
form.i.oninput = event => {
if (event && event.isTrusted && running()) stop();
form.value = values[form.i.valueAsNumber];
form.o.value = format(form.value, form.i.valueAsNumber, values);
this.updatechart(form.value);
};
form.b.onclick = () => {
if (running()) return stop();
direction = alternate && form.i.valueAsNumber === values.length - 1 ? -1 : 1;
form.i.valueAsNumber = (form.i.valueAsNumber + direction) % values.length;
form.i.dispatchEvent(new CustomEvent("input", {
bubbles: true
}));
start();
};
form.i.oninput();
if (autoplay) start();
else stop();
}

});
});

 

I hope this blog will help others to implement D3 charts/maps with the help of custom control in UI5 with less efforts.

I welcome any suggestion or improvements for the code and other feedback.

 

Thank you.

 

Regards,

Masoom Ahmad
Labels in this area