logo

NJP

Custom Scatter Plot: Use Multiple Data Channels to Show The Bigger Picture

Import · Nov 01, 2016 · article

In this post, I am going to outline how I created an interactive scatter plot in a Service Portal widget by leveraging D3.js. For this example, we are going to create the scatter plot using project records that are stored in our ServiceNow instance. What I would really like to highlight in this post is how you can use different data channels to tell different stories.

This particular scatter plot uses the following channels (how a particular shape can visually display data) to demonstrate different project attributes:

  1. X-axis displays the planned duration of the project
  2. Y-axis displays the planned cost of the project
  3. The size of each circle displays the planned effort of the project relative to the other projects displayed
  4. The color of each circle displays the priority of the project
  5. The line of best fit displays where the project sits in relation to the other projects in terms of planned duration vs. planned cost

Here is a full screenshot of this scatter plot to give a better idea of how these channels are being used:

image

Since there are previous posts giving a more in-depth introduction to using D3 and Service Portal together, I won't go into much detail with the code. Here is the pasted code for my HTML, CSS, Client Script, and Server Script:

HTML

CSS

.axis path,

.axis line {

fill: none;

stroke: #000;

shape-rendering: crispEdges;

}

.dot {

stroke: gray;

}

.dot:hover {

stroke: black;

}

.centered-chart {

text-align: center;

font: 10px sans-serif;

}

.p1 {

fill: #FF6347;

}

.p2 {

fill: #FFA500;

}

.p3 {

fill: #FFD700;

}

.p4 {

fill: #87CEFA;

}

.p5 {

fill: #90EE90;

}

Client Script

function() {

/* widget controller */

var c = this;

var data = c.data.projects;

var margin = {top: 20, right: 75, bottom: 75, left: 75},

width = 1170 - margin.left - margin.right,

height = 500 - margin.top - margin.bottom,

minCircle = 2, maxCircle = 20;

var maxDuration = d3.max(data, function(d) { return d.duration; });

var minDuration = d3.min(data, function(d) { return d.duration; });

// Set the colors used for each priority

var priorities = [{"priority": 1, "color": "#FF6347"},

{"priority": 2, "color": "#FFA500"},

{"priority": 3, "color": "#FFD700"},

{"priority": 4, "color": "#87CEFA"},

{"priority": 5, "color": "#90EE90"}];

// Set the scale for the x-axis

var x = d3.scaleLinear()

.range([0, width])

.domain([d3.min(data, function(d) { return d.duration; }) - 2, d3.max(data, function(d) { return d.duration; }) + 2]);

// Set the scale for the y-axis

var y = d3.scaleLinear()

.range([height, 0])

.domain([d3.min(data, function(d) { return d.cost; }) - 10000, d3.max(data, function(d) { return d.cost; }) + 10000]);

// Set the scale for the circle size

var r = d3.scaleLinear()

.domain([ 0, d3.max(data, function(d) {return d.effort;}) ]).range([ minCircle, maxCircle ]);

var xAxis = d3.axisBottom().scale(x);

var yAxis = d3.axisLeft().scale(y);

// Create an svg canvas for our chart

var svg = d3.select("#chart").append("svg")

.attr("id", "chart-container")

.attr("width", width + margin.left + margin.right)

.attr("height", height + margin.top + margin.bottom)

.append("g")

.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

// Create a circle for each of the project records

function generateCircles() {

svg.selectAll(".dot")

.data(data)

.enter().append("circle")

.attr("class", function(d) { return "dot p" + d.priority; })

.attr("r", function(d) { return r(d.effort); })

.attr("cx", function(d) { return x(d.duration); })

.attr("cy", function(d) { return y(d.cost); })

.on("mouseover",function(d,i){

this.parentNode.appendChild(this);

d3.select("#tooltip").attr("transform", "translate("+(x(d.duration)+20)+","+(y(d.cost)+20)+")");

d3.select("#duration").text("Duration: " + d.duration);

d3.select("#cost").text("Cost: $" + d.cost);

d3.select("#effort").text("Effort: " + d.effort + ' hours');

})

.on("mouseout",function(d,i){

d3.selectAll(".tooltip-attribute").text("");

});

}

// Create the priority legend

function generatePriorityLegend() {

var legend = svg.selectAll(".legend")

.data(priorities)

.enter().append("g")

.attr("class", "legend")

.attr("transform", function(d, i) { return "translate(0," + i * 20 + ")"; });

legend.append("rect")

.attr("x", width + 20)

.attr("width", 18)

.attr("height", 18)

.style("fill", function(d) { return d.color; });

legend.append("text")

.attr("x", width + 14)

.attr("y", 9)

.attr("dy", ".35em")

.style("text-anchor", "end")

.text(function(d) { return d.priority; });

var priorityLabel = d3.select("#chart-container")

.append("g")

.attr("transform", "translate(1130,55)")

.append("text")

.attr("transform", "rotate(-90)")

.attr("y", 6)

.attr("dy", ".71em")

.style("text-anchor", "end")

.text("Priority");

}

// Create initial tooltip

function generateTooltip() {

var tooltip = svg.append("g")

.append("g")

.attr("id", "tooltip");

tooltip.append("text")

.attr("id", "duration")

.attr("class", "tooltip-attribute");

tooltip.append("text")

.attr("id", "cost")

.attr("y", 15)

.attr("class", "tooltip-attribute");

tooltip.append("text")

.attr("id", "effort")

.attr("y", 30)

.attr("class", "tooltip-attribute");

}

// Add the x and y axes and their labels

function generateAxes() {

svg.append("g")

.attr("class", "x axis")

.attr("transform", "translate(0," + height + ")")

.call(xAxis);

svg.append("text")

.attr("x", width)

.attr("y", height - 6)

.style("text-anchor", "end")

.text("Duration (Days)");

svg.append("g")

.attr("class", "y axis")

.call(yAxis);

svg.append("text")

.attr("transform", "rotate(-90)")

.attr("y", 6)

.attr("dy", ".71em")

.style("text-anchor", "end")

.text("Cost ($)");

}

// Create the line of best fit

function generateLine() {

var linReg = linearRegression(data);

var myLine = svg.append("svg:line")

.attr("x1", x(minDuration))

.attr("y1", y(minDuration*linReg.slope + linReg.intercept))

.attr("x2", x(maxDuration))

.attr("y2", y( (maxDuration*linReg.slope) + linReg.intercept ))

.style("stroke", "#A9A9A9");

function linearRegression(projects){

var lr = {};

var n = projects.length;

var sum_x = 0;

var sum_y = 0;

var sum_xy = 0;

var sum_xx = 0;

var sum_yy = 0;

for (var i = 0; i < projects.length; i++) {

sum_x += projects[i].duration;

sum_y += projects[i].cost;

sum_xy += (projects[i].duration*projects[i].cost);

sum_xx += (projects[i].duration*projects[i].duration);

sum_yy += (projects[i].cost*projects[i].cost);

}

lr.slope = (n * sum_xy - sum_x * sum_y) / (n*sum_xx - sum_x * sum_x);

lr.intercept = (sum_y - lr.slope * sum_x)/n;

lr.r2 = Math.pow((n*sum_xy - sum_x*sum_y)/

Math.sqrt((n*sum_xx-sum_x*sum_x)*(n*sum_yy-sum_y*sum_y)),2);

return lr;

}

}

generateCircles();

generatePriorityLegend();

generateTooltip();

generateAxes();

generateLine();

}

Server Script

(function() {

var projData = [];

// Grab projects in the IT porfolio

var projGR = new GlideRecord('pm_project');

projGR.addQuery('primary_portfolio', '30e14b3beb131100b749215df106fe0f');

projGR.addEncodedQuery("resource_planned_cost>javascript:getCurrencyFilter('pm_project','resource_planned_cost', 'USD;0')effort>javascript:gs.getDurationDate('0 0:0:0')");

projGR.orderByDesc('start_date');

projGR.query();

// Push each project record into an array to pass to the client script

while (projGR.next()) {

var dc = new DurationCalculator();

var hours = dc.calcScheduleDuration('1970-01-01 00:00:00', projGR.effort)/60/60;

var duration = dc.calcScheduleDuration('1970-01-01 00:00:00', projGR.duration)/60/60/24;

projData.push({

"name": projGR.short_description+'',

"cost": +projGR.cost,

"duration": +duration,

"effort": +hours,

"priority": projGR.priority+''

});

}

data.projects = projData;

})();

Mitch Stutler

VividCharts Founder

vividcharts.com

linkedin.com/in/mitchellstutler

twitter.com/mitchstutler

Sources

View original source

https://www.servicenow.com/community/developer-blog/custom-scatter-plot-use-multiple-data-channels-to-show-the/ba-p/2287995