- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
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:
- X-axis displays the planned duration of the project
- Y-axis displays the planned cost of the project
- The size of each circle displays the planned effort of the project relative to the other projects displayed
- The color of each circle displays the priority of the project
- 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:
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
<div class="centered-chart row">
<div id="chart"></div>
</div>
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
linkedin.com/in/mitchellstutler
Sources
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.