Skip navigation

Developer Community

6 Posts authored by: Mitchell Stutler

The Service Portal has an extremely useful feature called Record Watch. Record Watch allows you to configure a listener function that notifies your widget when certain database actions take place. When you have a Record Watch function configured, your widget can automatically adjust itself accordingly.

 

In this example, I am going to explain how I added a Record Watch listener function that automatically increases the size of a bar in a bar chart when a matching record is added. This will build on a previous post of mine which can be found here, so this post will strictly focus on the Record Watch portion.

 

Record Watch Function

 

First, you'll want to inject spUtil into your client script function parameters. I'll post my full client script at the end in case you aren't sure where to put this.

 

Here is my Record Watch function which I will walk through:

 

spUtil.recordWatch($scope, "incident", "active=true", function(name, d) {                     

            if (d.action == 'entry') {

                        for (i=0; i < $scope.activeData.length; i++) {

                                    if (d.record.category.display_value == $scope.activeData[i].category) {

                                                $scope.activeData[i].value++;

                                                break;

                                    }

                        }

                        $scope.updateBars($scope.activeData);

            }

});

 

In the first line, we call the Record Watch function from spUtil. The second parameter we pass is the table that we want to listen to and the third parameter is the filter so we only get notifications for the specific types of records we want. Lastly, we create an anonymous function that will allow us to make sense of the notification we receive from our Record Watch function.

 

We are passing the parameters of name and d to our anonymous function. The name will provide information about the update. The d parameter contains information about the action type as well as the information from the record that was updated. I encourage you to log these 2 objects to your console so you can explore them to get a better feel for what we get back from Record Watch.

  

You can see that inside of my anonymous function I am only looking for inserted records by using if (d.action == 'entry'). When I get a matching notification, I check the newly created incident's category and increment the bar that has a matching category.

 

This is just one example out of infinite possibilities of how you can use the Record Watch functionality. My specific thought behind this example is that you could create a dashboard that doesn't need to be refreshed because the widgets automatically adjust according to the Record Watch notifications.

 

Listening Bar Chart.png

Client Script

 

function(spUtil, $scope) {

            /* widget controller */

            var c = this;

         

            // Grab our category counts from our Server Script

            $scope.activeData = c.data.active;

            $scope.inactiveData = c.data.inactive;

            $scope.allData = c.data.all;

         

            // Set the width of the chart along with the height of each bar

            var width = c.options.width,

            barHeight = c.options.bar_height,

            leftMargin = c.options.left_margin;

         

            $scope.updateBars = function(data) {        

                        // Set the dimensions of our chart

                        var chart = d3.select(".chart").attr("width", width)

                        .attr("height", barHeight * data.length + 50);

 

 

                        // Remove existing axis and tooltip

                        d3.select(".x.axis").remove();

                        chart.select(".counter").remove();

                     

                        // Add a placeholder text element for our tooltip

                        var counter = chart.append("text").attr("class", "counter")

                                    .attr("y", 10)

                                    .attr("x", width-20);

                     

                        // Set the domain and range of the chart

                        var x = d3.scaleLinear()

                                    .range([leftMargin, width])

                                    .domain([0, d3.max(data, function(d) { return d.value * 1; }) + 10]);

 

 

                        // Bind our new data to our g elements

                        var bar = chart.selectAll("g").data(data, function(d) { return d.category;});

                     

                        // Remove existing bars that aren't in the new data

                        bar.exit().remove();

                     

                        // Create new g elements for new categories in our new data

                        var barEnter = bar.enter().append("g")

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

                     

                        // Enter new rect elements

                        barEnter.append("rect")

                                    .on("mouseover", highlightBar)

                                    .on("mouseout", unhighlightBar)

                                    .attr("class", "chart-bar")

                                    .attr("height", barHeight - 1)

                                    .attr("x", leftMargin)

                                    .transition().duration(750)

                                    .attr("width", function(d) { return x(d.value) - leftMargin; });

                                 

                        // Enter new text labels

                        barEnter.append("text")

                                    .attr("x", leftMargin - 5)

                                    .attr("y", barHeight / 2)

                                    .attr("width", leftMargin)

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

                                    .style("fill", "black")

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

                                    .transition()

                                    .delay(750)

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

                     

                        // Update existing bars

                        bar.transition().duration(750)

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

                                             

                        bar.selectAll('rect')

                                    .on("mouseover", highlightBar)

                                    .on("mouseout", unhighlightBar)

                                    .data(data, function(d) { return d.category;})

                                    .transition().duration(750)

                                    .attr("width", function(d) { return x(d.value) - leftMargin; });

                     

                        // Create the x-axis and append it to the bottom of the chart      

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

         

                        chart.append("g")

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

                                    .attr("transform", "translate(0," + (barHeight * data.length) + ")")

                                    .attr("x", leftMargin)

                                    .call(xAxis);

                     

                        // Define functions for our hover functionality

                        function highlightBar(d,i) {

                                    d3.select(this).style("fill", "#b0c4de");                   

                                    counter.text(d.category + ' ' + d.value);      

                        }

                     

                        function unhighlightBar(d,i) {

                                    d3.select(this).style("fill", "#4682b4");

                                    counter.text("");

                        }

                     

            }

         

            spUtil.recordWatch($scope, "incident", "active=true", function(name, d) {                     

                        if (d.action == 'entry') {

                                    for (i=0; i < $scope.activeData.length; i++) {

                                                if (d.record.category.display_value == $scope.activeData[i].category) {

                                                            $scope.activeData[i].value++;

                                                            break;

                                                }

                                    }

                                    $scope.updateBars($scope.activeData);

                        }

  });

         

            $scope.updateBars($scope.activeData);

         

}

 

Sources

- Record Watch

- d3js.org

 

NOTE: MY POSTINGS REFLECT MY OWN VIEWS AND DO NOT NECESSARILY REPRESENT THE VIEWS OF MY EMPLOYER, ACCENTURE.

In this post, I will outline how I was able to create a treemap in a Service Portal widget using D3.js. Treemaps convert hierarchical data into a conglomeration of nested rectangles that represent the data values.

 

This particular widget example will query the ServiceNow catalog categories and catalog items that are in my personal developer instance to generate the data object that will be visually expressed in my D3 treemap. As a business use case, you would probably want to query the Requested Item table to display which items and categories are the most frequently ordered. My developer instance doesn't have much Requested Item data, so I generated random numbers to better display the treemap functionality.

 

Below is a screenshot of my treemap widget in action:

D3 Treemap.png

Each color represents a single catalog category and each rectangle represents a catalog item. The size of the catalog item rectangle is scaled according to how many times that item has been ordered. The bigger the rectangle, the more that item has been ordered. I also added the ability to resize the rectangles to equal sizes to display category sizes based on how many items live under it. To change between these two views, I set up radio buttons to trigger the transition. Below is a screenshot of the second view:

 

D3 Treemap Categories.png

 

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">

     <h1>D3 Treemap</h1>

     <svg width="960" height="570"></svg>

</div>

<form>

     <label><input type="radio" name="mode" value="sumBySize" checked> Ordered Count</label>

     <label><input type="radio" name="mode" value="sumByCount"> Category Size</label>

</form>

 

CSS

 

form {
   padding-left: 150px;
   font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}

.centered-chart {
   text-align: center;
   font: 10px sans-serif;
}

 

Client Script

 

function() {

            /* widget controller */

            var c = this;

          

            // Grab our category object from the data object

            var categories = c.data.categories;

          

            var svg = d3.select("svg"),

            width = +svg.attr("width"),

            height = +svg.attr("height");

          

            var fader = function(color) { return d3.interpolateRgb(color, "#fff")(0.2); },

            color = d3.scaleOrdinal(d3.schemeCategory20.map(fader)),

            format = d3.format(",d");

                      

            // Define our D3 treemap object

            var treemap = d3.treemap()

            .tile(d3.treemapResquarify)

            .size([width, height])

            .round(true)

            .paddingInner(1);

          

            // Define function that draws treemap based on the data parameter

            function loadData(data) {

                      

                        var root = d3.hierarchy(data)

                        .eachBefore(function(d) {

                                    d.data.id = (d.parent ? d.parent.data.id + "." : "") + d.data.name;

                                    d.data.title = (d.parent ? d.parent.data.name + " > " : "") + d.data.name;

                        })

                        .sum(sumBySize)

                        .sort(function(a, b) { return b.height - a.height || b.value - a.value; });

                      

                        treemap(root);

                      

                        var cell = svg.selectAll("g")

                        .data(root.leaves())

                        .enter().append("g")

                        .attr("transform", function(d) { return "translate(" + d.x0 + "," + d.y0 + ")"; });

                      

                        cell.append("rect")

                        .attr("id", function(d) { return d.data.id; })

                        .attr("width", function(d) { return parseInt(d.x1 - d.x0); })

                        .attr("height", function(d) { return parseInt(d.y1 - d.y0); })

                        .attr("fill", function(d) { return color(d.parent.data.id); });

                      

                        cell.append("clipPath")

                        .attr("id", function(d) { return "clip-" + d.data.id; })

                        .append("use")

                        .attr("xlink:href", function(d) { return "#" + d.data.id; });

                      

                        cell.append("text")

                        .attr("clip-path", function(d) { return "url(#clip-" + d.data.id + ")"; })

                        .selectAll("tspan")

                        .data(function(d) { return d.data.name.split(/(?=[A-Z][^A-Z])/g); })

                        .enter().append("tspan")

                        .attr("x", 4)

                        .attr("y", function(d, i) { return 13 + i * 10; })

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

                      

                        cell.append("title")

                        .text(function(d) { return d.data.title + "\n" + format(d.value) + " Requested"; });

                      

                        d3.selectAll("input")

                        .data([sumBySize, sumByCount], function(d) { return d ? d.name : this.value; })

                        .on("change", changed);

                      

                        // Set timeout that will automatically change our treemap to demonstrate transitions

                        var timeout = d3.timeout(function() {

                                    d3.select("input[value=\"sumByCount\"]")

                                    .property("checked", true)

                                    .dispatch("change");

                        }, 2000);

                      

                        function changed(sum) {

                                    timeout.stop();

                                  

                                    treemap(root.sum(sum));

                                  

                                    cell.transition()

                                    .duration(750)

                                    .attr("transform", function(d) { return "translate(" + d.x0 + "," + d.y0 + ")"; })

                                    .select("rect")

                                    .attr("width", function(d) {

                                                var width = parseInt(d.x1 - d.x0);

                                                console.log(typeof width);

                                                return width;

                                    })

                                    .attr("height", function(d) {

                                                var height = parseInt(d.y1 - d.y0);

                                                console.log(typeof height);

                                                return height;

                                    });

                        }

            }

          

            // Call our data load function to initially draw the treemap with our data object

            loadData(categories);

          

            function sumByCount(d) {

                        return d.children ? 0 : 1;

            }

          

            function sumBySize(d) {

                        return d.size;

            }

          

}

 

Server Script

 

(function() {

/* populate the 'data' object */

 

// Query catalog items in the Service Catalog

var catGR = new GlideRecord('sc_cat_item');

catGR.addActiveQuery();

// I hardcoded the sys_id of the Service Catalog here. This could definitely

// be dynamically set up as a widget option.

catGR.addQuery('sc_catalogs', 'e0d08b13c3330100c8b837659bba8fb4');

catGR.addNotNullQuery('category.title');

catGR.orderBy('category');

catGR.query();

          

// Declare our object that will contain the item data

var cats = {

"name": "Service Catalog",

"children": []

}

          

var previousCat = '';

var tempArray = [];

          

// Loop through items and populate our cats object according to the

// category structure. I don't have great RITM data in my personal

// dev instance, so I used random numbers to imitate the counts.

while (catGR.next()) {

var category = catGR.category.title+'';

                      

if (previousCat == category)

tempArray.push({"name": catGR.name+'', "size": Math.floor((Math.random() * 100) + 1)});

else {

cats.children.push({"name": previousCat, "children": tempArray});

tempArray = [];

}

previousCat = category;

}

          

// Pass our category object to the data object to be used client side

data.categories = cats;

          

})();

 

 

Sources

- d3js.org

- Treemap

 

NOTE: MY POSTINGS REFLECT MY OWN VIEWS AND DO NOT NECESSARILY REPRESENT THE VIEWS OF MY EMPLOYER, ACCENTURE.

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:

Post 6 Scatter Plot.png

 

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;

         

})();

 

Sources

 

For a full collection of my posts, visit my Community Blogs or http://mitchstutler.com/blog.

 

NOTE: MY POSTINGS REFLECT MY OWN VIEWS AND DO NOT NECESSARILY REPRESENT THE VIEWS OF MY EMPLOYER, ACCENTURE.

In this post, I am going to outline how I created an interactive heat map in a Service Portal widget by leveraging D3.js. This heat map displays a matrix of colored blocks that indicate how many incidents were created at a given time on a given week day. This could help you forecast service desk scheduling needs, plan when to implement changes, or even identify your highest risk periods.

 

Post 5 Total.png

 

Given that this post is using incident data from my instance, I also added in the capability to click a button and transition the heat map to only focus on a particular incident priority. I'm sure that you can imagine multiple use cases for this heat map, so I suggest you view this post as a framework which you can modify to your own needs.

 

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 are screenshots and/or pasted code for my HTML, CSS, Client Script, and Server Script:

 

HTML

 

Post 5 HTML.png

 

<div class="centered-chart row">

     <div id="chart"></div>

     <button class="btn" ng-click="c.updateMap(c.data.blocks, 'total')">Total</button>

     <button class="btn" ng-click="c.updateMap(c.data.blocks, 'p1')">Priority 1</button>

     <button class="btn" ng-click="c.updateMap(c.data.blocks, 'p2')">Priority 2</button>

     <button class="btn" ng-click="c.updateMap(c.data.blocks, 'p3')">Priority 3</button>

     <button class="btn" ng-click="c.updateMap(c.data.blocks, 'p4')">Priority 4</button>

     <button class="btn" ng-click="c.updateMap(c.data.blocks, 'p5')">Priority 5</button>

</div>

 

CSS

 

Post 5 CSS.png

 

rect.bordered {

     stroke: #E6E6E6;

     stroke-width:2px;

}

 

text.mono {

     font-size: 9pt;

     font-family: Consolas, courier;

     fill: #aaa;

}

 

text.axis-workweek {

     fill: #000;

}

 

text.axis-worktime {

     fill: #000;

}

 

.btn {

     background-color: white;

     border: 1px solid gray !important;

}

 

.centered-chart {

     text-align: center;

}

 

Client Script

 

function() {

            /* widget controller */

            var c = this;

         

            var margin = { top: 50, right: 0, bottom: 100, left: 30 },

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

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

            gridSize = Math.floor(width / 24),

            legendElementWidth = gridSize*2,

            buckets = 9,

            colors = ["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#253494","#081d58"],

            days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],

            times = ["1a", "2a", "3a", "4a", "5a", "6a", "7a", "8a", "9a", "10a", "11a", "12a", "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", "10p", "11p", "12p"];

         

            // Create the chart svg using the defined sizes

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

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

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

            .append("g")

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

         

            // Create the update function that takes the data and count property as parameters

            c.updateMap = function(data, count) {

                        // Create day labels

                        var dayLabels = svg.selectAll(".dayLabel")

                        .data(days)

                        .enter().append("text")

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

                        .attr("x", 0)

                        .attr("y", function (d, i) { return i * gridSize; })

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

                        .attr("transform", "translate(-6," + gridSize / 1.5 + ")")

                        .attr("class", function (d, i) { return ((i >= 0 && i <= 4) ? "dayLabel mono axis axis-workweek" : "dayLabel mono axis"); });

                     

                        // Create time labels

                        var timeLabels = svg.selectAll(".timeLabel")

                        .data(times)

                        .enter().append("text")

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

                        .attr("x", function(d, i) { return i * gridSize; })

                        .attr("y", 0)

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

                        .attr("transform", "translate(" + gridSize / 2 + ", -6)")

                        .attr("class", function(d, i) { return ((i >= 7 && i <= 16) ? "timeLabel mono axis axis-worktime" : "timeLabel mono axis"); });

                     

                        // Creates color scale based on the number of buckets and

                        var colorScale = d3.scaleQuantile()

                        .domain([0, buckets - 1, d3.max(data, function (d) { return d[count]; })])

                        .range(colors);

                     

                        // Enter and update blocks

                        var blocks = svg.selectAll(".hour")

                        .data(data, function(d) {return d.day+':'+d.hour;});

                     

                        blocks.enter().append("rect")

                        .attr("x", function(d) { return (d.hour - 1) * gridSize; })

                        .attr("y", function(d) { return (d.day - 1) * gridSize; })

                        .attr("rx", 4)

                        .attr("ry", 4)

                        .attr("class", "hour bordered")

                        .attr("width", gridSize)

                        .attr("height", gridSize)

                        .style("fill", colors[0])

                        .transition().duration(1500)

                        .style("fill", function(d) { return colorScale(d[count]); });

                     

                        blocks.transition().duration(1500)

                        .style("fill", function(d) { return colorScale(d[count]); });

                     

                        // Create the new legend and remove the existing legend           

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

                        .data([0].concat(colorScale.quantiles()), function(d) { return d; });

                     

                        var legendEnter = legend.enter().append("g")

                        .attr("class", "legend");

         

                        legendEnter.append("rect")

                        .attr("x", function(d, i) { return legendElementWidth * i; })

                        .attr("y", height)

                        .attr("width", legendElementWidth)

                        .attr("height", gridSize / 2)

                        .style("fill", function(d, i) { return colors[i]; })

                     

                        legendEnter.append("text")

                        .attr("class", "mono")

                        .text(function(d) { return "≥ " + Math.round(d); })

                        .attr("x", function(d, i) { return legendElementWidth * i; })

                        .attr("y", height + gridSize);

                     

                        legend.exit().remove();

            };

         

            // Function that sets the initial blocks while waiting for the server data to be returned

            function setInitialBlocks() {

                        var intialBlocks = [];

 

                        for (i=1;i<8;i++) {

                                    for (j=1;j<25;j++) {

                                                intialBlocks.push({"day": i, "hour": j, "total": 0});

                                    }

                        }

                     

                        c.updateMap(intialBlocks, "total");

            }

         

            // Function to retrieve block data from the server script

            c.display = function() {

                        c.server.update().then(function(data) {

                                    c.updateMap(c.data.blocks, "total");

                        })

            }

         

            setInitialBlocks();

            c.display();

}

 

Server Script

 

(function() {

         

            if (input) {

                        var incData = [];

                     

                        for (i=1;i<8;i++) {

                                    for (j=1;j<25;j++) {

                                                incData.push({"id": i + ":" + j, "day": i, "hour": j, "total": 0, "p1": 0,

                                                "p2": 0, "p3": 0, "p4": 0, "p5": 0});

                                    }

                        }

                     

                        // Query the incident table and start totaling the number of records for each priority

                        var incGR = new GlideRecord('incident');

                        incGR.orderByDesc('opened_at');

                        incGR.setLimit(3000);

                        incGR.query();

                        while (incGR.next()) {

                                    var hour = incGR.opened_at.slice(11,13);

                                    var gdt = new GlideDateTime(incGR.opened_at);

                                    var day = gdt.getDayOfWeek();

                                    var key = day+':'+hour;

                                    var block = incData.filter(findBlock(key));

                                    if (block) {

                                                block[0].total++;

                                                switch(incGR.priority+'') {

                                                            case '1':

                                                            block[0].p1++;

                                                            break;

                                                            case '2':

                                                            block[0].p2++;

                                                            break;

                                                            case '3':

                                                            block[0].p3++;

                                                            break;

                                                            case '4':

                                                            block[0].p4++;

                                                            break;

                                                            case '5':

                                                            block[0].p5++;

                                                            break;

                                                }

                                    }

                        }

                     

                        data.blocks = incData;

            }

         

            function findBlock(key) {

                        return function(element) {

                                    return element.id == key;

                        }

            }   

})();

 

D3 Transitions

 

By clicking one of the priority buttons, we can transition our heat map to only look at our incidents with that priority. Although screenshots won't demo the aesthetically pleasing, gradual D3 transitions used in our widget, here is an example:

 

All incidents:

Post 5 Total.png

 

Priority 4 incidents:

Post 5 Priority 4.png

 

Sources

- https://d3js.org/

- Day / Hour Heatmap

 

For a full collection of my posts, visit http://mitchstutler.com/blog

 

NOTE: MY POSTINGS REFLECT MY OWN VIEWS AND DO NOT NECESSARILY REPRESENT THE VIEWS OF MY EMPLOYER, ACCENTURE.

In this post, we are going to build a Service Portal widget with an interactive bar chart with multiple data sets. This is the third post in my series that focuses on using D3js within ServiceNow's Service Portal. If you haven't already worked through the previous posts and are finding yourself lost, you might want to check those out here.

 

New concepts to the series that we will touch on in this post are entering, updating, and exiting data with D3, working with transitions to change our data in a pleasing manner, and adding basic interactive aspects such as hovering effects. The key pieces of our widget that we are going to be focusing on are our server script and our client script.

 

Server script

 

For this chart, we are going to have the ability to view the counts of active, inactive, and all incidents by category. There are multiple ways to get this data from our server script, but for simplicity's sake we are just going to use 3 separate GlideAggregate calls and push that data into one of 3 arrays defined on the data object. Each of these arrays will contain an object for each category returned from the GlideAggregate call. Each of these objects will look like this after our server script:

 

{category: "Software", "value": 14}

 

Below is a screenshot of my server script as well as the pasted code:

 

Post 4 Server Script.png

 

(function() {

       /* populate the 'data' object */

       options.width = options.width || 600;

       options.bar_height = options.bar_height || 20;

       options.left_margin = options.left_margin || 100;

 

       data.active = [];

       data.inactive = [];

       data.all = [];

 

       // Get count of active incidents

       var count = new GlideAggregate('incident');

       count.addQuery('active', 'true');

       count.addAggregate('COUNT', 'category');

       count.query();

       while (count.next()) {

            var category = count.category.getDisplayValue();

            var categoryCount = count.getAggregate('COUNT', 'category');

            data.active.push({category: category, "value": categoryCount});

       }

 

       / / Get count of inactive incidents

       var inactiveCount = new GlideAggregate('incident');

       inactiveCount.addQuery('active', 'false');

       inactiveCount.addAggregate('COUNT', 'category');

       inactiveCount.query();

       while (inactiveCount.next()) {

            var category = inactiveCount.category.getDisplayValue();

            var categoryCount = inactiveCount.getAggregate('COUNT', 'category');

            data.inactive.push({category: category, "value": categoryCount});

       }

 

       // Get count of all incidents

       var allCount = new GlideAggregate('incident');

       allCount.addAggregate('COUNT', 'category');

       allCount.query();

       while (allCount.next()) {

            var category = allCount.category.getDisplayValue();

            var categoryCount = allCount.getAggregate('COUNT', 'category');

            data.all.push({category: category, "value": categoryCount});

       }

})();

 

Client script

 

Since we are working with multiple data sets, we are going to set up a function that takes a data set as a parameter and then updates our bar chart accordingly. This gives us the ability to call this function from buttons in our widget so the user can navigate through these data sets. You might also notice at the end of our client script that we are calling this function with one of the data sets so that the bar chart will be present after the widget loads.

 

When working with multiple data sets in D3, there are 3 major pieces to the process of changing data sets: entering new data, updating existing data that is also present in the new data set, and exiting existing data that isn't in the new data set. For this example, I enter the new data, then I exit any of the bars that are no longer needed, then enter any new bars, and finally update the bars that are leftover from the previous data set.

 

Something else that is new from the previous post is that we will use a key function when we bind our data set. For this chart, we are going to use our category name as the key value:

 

var bar = chart.selectAll("g").data(data, function(d) { return d.category;});

 

What this allows us to do is update a bar that has values in multiple data sets. For example, the category "Software" appears in both the active and inactive data sets. Since we are keying off of the category name value, we will just update the existing "Software" bar to its new size instead of removing it and then adding a brand new bar.

 

Now that we are able to update existing bars we will use D3 transitions to gradually change from the existing width to the new width. We will also use transitions for entering new data bars.

 

The last new piece of functionality that we are adding in this post is attaching mouse-over and mouse-out functions to our bars to give an extra layer of interactive capabilities. Our mouse-over function is going to change the color of the bar that is being hovered on as well as show a tool-tip that provides the exact count of records for that given bar.  Below is the code for our function:

 

function($scope) {

  /* widget controller */

  var c = this;

 

  // Grab our category counts from our Server Script

  $scope.activeData = c.data.active;

  $scope.inactiveData = c.data.inactive;

  $scope.allData = c.data.all;

 

  // Set the width of the chart along with the height of each bar

  var width = c.options.width,

  barHeight = c.options.bar_height,

  leftMargin = c.options.left_margin;

 

  $scope.updateBars = function(data) {

       // Set the dimensions of our chart

       var chart = d3.select(".chart").attr("width", width)

       .attr("height", barHeight * data.length + 50);

 

       // Remove existing axis and tooltip

       d3.select(".x.axis").remove();

       chart.select(".counter").remove();

 

       // Add a placeholder text element for our tooltip

       var counter = chart.append("text").attr("class", "counter")

       .attr("y", 10)

       .attr("x", width-20);

 

       // Set the domain and range of the chart

       var x = d3.scaleLinear()

       .range([leftMargin, width])

       .domain([0, d3.max(data, function(d) { return d.value; })]);

 

       // Bind our new data to our g elements

       var bar = chart.selectAll("g").data(data, function(d) { return d.category;});

 

       // Remove existing bars that aren't in the new data

       bar.exit().remove();

 

       // Create new g elements for new categories in our new data

       var barEnter = bar.enter().append("g")

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

 

       // Enter new rect elements

       barEnter.append("rect")

       .on("mouseover", highlightBar)

       .on("mouseout", unhighlightBar)

       .attr("class", "chart-bar")

       .attr("height", barHeight - 1)

       .attr("x", leftMargin)

       .transition().duration(750)

       .attr("width", function(d) { return x(d.value) - leftMargin; });

 

       // Enter new text labels

       barEnter.append("text")

       .attr("x", leftMargin - 5)

       .attr("y", barHeight / 2)

       .attr("width", leftMargin)

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

       .style("fill", "black")

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

       .transition()

       .delay(750)

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

 

       // Update existing bars

       bar.transition().duration(750)

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

 

       bar.selectAll('rect')

       .on("mouseover", highlightBar)

       .on("mouseout", unhighlightBar)

       .data(data, function(d) { return d.category;})

       .transition().duration(750)

       .attr("width", function(d) { return x(d.value) - leftMargin; });

 

       // Create the x-axis and append it to the bottom of the chart

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

 

       chart.append("g")

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

       .attr("transform", "translate(0," + (barHeight * data.length) + ")")

       .attr("x", leftMargin)

       .transition()

       .delay(750)

       .call(xAxis);

 

 

       // Define functions for our hover functionality

       function highlightBar(d,i) {

            d3.select(this).style("fill", "#b0c4de");

            counter.text(d.category + ' ' + d.value);

       }

 

       function unhighlightBar(d,i) {

            d3.select(this).style("fill", "#4682b4");

            counter.text("");

       }

 

  }

 

  $scope.updateBars($scope.activeData);

 

}

 

HTML

 

Below is a screenshot and pasted code for our HTML:

 

Post 4 HTML.png

 

<div class="centered-chart row">

     <h1>D3 Bar Chart</h1>

     <div class="chart-container">

          <svg class="chart"></svg>

     </div>

     <div class="button-container">

          <button class="btn" ng-click="updateBars(activeData)">Active</button>

          <button class="btn" ng-click="updateBars(inactiveData)">Inactive</button>

          <button class="btn" ng-click="updateBars(allData)">All</button>

     </div>

</div>

 

CSS

 

Below is a screenshot and pasted code for our CSS:

 

Post 4 CSS.png

 

.btn {

     background-color: white;

     border: 1px solid gray !important;

}

 

.chart rect {

     fill: #4682b4;

}

 

.chart-container {

     height: 200px;

}

 

.chart text {

     font: 10px sans-serif;

}

 

.centered-chart {

     text-align: center;

}

 

.counter {

     text-anchor: end;

}

 

.axis text {

font: 10px sans-serif;

}

 

.axis path,

.axis line {

     fill: none;

     stroke: #000;

     shape-rendering: crispEdges;

}

 

Finished product

 

Now that we have everything in place, we can test it out. If you followed along on your own instance correctly, your widget should look similar to this:

 

Post 4 Bar Chart.png

 

If yours looks different, it could be that the data in our instances are different. Otherwise, check out the previous posts in this series and see if you missed a step.

 

We now have what we need to work with multiple data sets to create interactive data visualization widgets in the Service Portal. Keep an eye out for future posts that will build on these foundation blocks of D3 and Service Portal.

 

Sources

- ServiceNow GlideAggregate

- https://d3js.org/

 

For a full collection of my posts, visit http://mitchstutler.com/blog

 

NOTE: MY POSTINGS REFLECT MY OWN VIEWS AND DO NOT NECESSARILY REPRESENT THE VIEWS OF MY EMPLOYER, ACCENTURE.

In this post we’re going to create a Service Portal widget that displays a United States map that colors the individual states based on the amount of incidents opened in that state relative to the other states. We are going to use D3js to accomplish this. If you are unfamiliar with D3, check out this previous post introducing D3 to ServiceNow. This post will follow an example from the D3 community which can be found here.

 

To create our state map we will need to grab our incident data in our server script, create a dependency to a new UI script that contains the coordinates for the state map, and pass our data to the UI script from our client script.

 

Server script

 

In our server script, we will create an array of objects within the data object. This array will contain 50 objects; each representing a state. Once that array of objects is created, we will use a pair of GlideAggregate calls to retrieve the counts of open and closed incidents by location. We’ll use these calls to populate our array of objects with the counts for each state.

 

Below is a screenshot of the server script along with the pasted script:

 

Post 3 Server Script.png

 

 

(function() {

            // Create an array of state abbreviations in the same order as our UI script

            var states = ["HI", "AK", "FL", "SC", "GA", "AL", "NC", "TN", "RI", "CT", "MA",

            "ME", "NH", "VT", "NY", "NJ", "PA", "DE", "MD", "WV", "KY", "OH",

            "MI", "WY", "MT", "ID", "WA", "DC", "TX", "CA", "AZ", "NV", "UT",

            "CO", "NM", "OR", "ND", "SD", "NE", "IA", "MS", "IN", "IL", "MN",

            "WI", "MO", "AR", "OK", "KS", "LA", "VA"];

         

            // Create an array of objects in our data object with placeholder properties

            data.states = [];

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

                        data.states.push({state: states[i], open: 0, closed: 0});

            }

         

            // Find counts of open incidents by location

            var openCount = new GlideAggregate('incident');

            openCount.addQuery('active', 'true');

            openCount.addAggregate('COUNT', 'location');

            openCount.query();

            while (openCount.next()) {

                        var openState = openCount.location.state;

                        var openStateCount = openCount.getAggregate('COUNT', 'location')*1;

                        for (i=0; i<data.states.length; i++) {

                                    // Increase the open property if there is a match with a state

                                    if (openState == data.states[i].state) {

                                                data.states[i].open += openStateCount;

                                                break;

                                    }

                        }

            }

         

            // Find counts of closed incidents by location

            var closedCount = new GlideAggregate('incident');

            closedCount.addQuery('active', 'false');

            closedCount.addAggregate('COUNT', 'location');

            closedCount.query();

            while (closedCount.next()) {

                        var closedState = closedCount.location.state;

                        var closedStateCount = closedCount.getAggregate('COUNT', 'location')*1;

                        for (i=0; i<data.states.length; i++) {

                                    // Increase the closed property if there is a match with a state

                                    if (closedState == data.states[i].state) {

                                                data.states[i].closed += closedStateCount;

                                                break;

                                    }

                        }

            }

         

})();

 

UI script

 

Similar to the widget dependency we created in the previous posts, we will create dependencies to call D3 and also to call a UI script that we will create. The code for our new UI script can be found here. It doesn’t matter what you name this UI script as long as you remember what you name it; I named mine u_states. Once you have created this UI script, reference it with a widget script dependency.

 

Client Script

 

Our client script will be used to determine what color each state should be, define the HTML template that will be used as a tooltip, and make the call to our new UI script to actually draw our state map.

 

We will loop through the array of objects that we created in our server script and use the D3 interpolate to determine each state’s color. The D3 interpolate allows us to define a range of two colors: one for the lowest incident density and one for the highest incident density. Based on a given state’s incident count, its color will fall somewhere in this range. For this example we will use #ffffcc as our low color and #800026 as our high color, but you can use any colors you want.

 

Below is a screenshot of my client script along with the pasted script:

 

Post 3 Client Script.png

 

function() {

            /* widget controller */

            var c = this;

         

            // Find the max number of open incidents in a single state

            var maxOpen = d3.max(c.data.states, function(d) { return d.open; });

 

            // Create object containing the state data and determine what color

            // each state should be

            var mapData ={};

         

            c.data.states

            .forEach(function(d){

                        mapData[d.state] = {color: d3.interpolate("#ffffcc", "#800026")(d.open/maxOpen), open: d.open, closed: d.closed};

            });

         

            // Define the HTML for the tooltip

            function tooltipHtml(n, d){

            return "<h4>"+n+"</h4><table>"+

            "<tr><td>Open </td><td>"+(d.open)+"</td></tr>"+

            "<tr><td>Closed </td><td>"+(d.closed)+"</td></tr>"+

            "<tr><td>Total </td><td>"+(d.open + d.closed)+"</td></tr>"+

            "</table>";

            }

 

            // Calls the draw function from our u_states UI script which uses

            // D3 to draw our map

            uStates.draw("#statesvg", mapData, tooltipHtml);

 

            d3.select(self.frameElement).style("height", "800px");

}

 

HTML

 

Below is the pasted HTML I used for this widget:

 

<div id="tooltip"></div>

<div style="text-align: center;">

            <h2>Incident State Map</h2>

            <svg width="960" height="600" id="statesvg"></svg>

</div>

 

CSS

 

Below is the pasted CSS I used for this widget:

 

.state{

            fill: none;

            stroke: #a9a9a9;

            stroke-width: 1;

}

.state:hover{

            fill-opacity:0.5;

}

#tooltip {

            position: absolute;        

            text-align: center;

            padding: 20px;          

            margin: 10px;

            font: 12px sans-serif;     

            background: lightsteelblue;

            border: 1px;   

            border-radius: 2px;        

            pointer-events: none;      

}

#tooltip h4{

            margin:0;

            font-size:14px;

}

#tooltip{

            background:rgba(0,0,0,0.9);

            border:1px solid grey;

            border-radius:5px;

            font-size:12px;

            width:auto;

            padding:4px;

            color:white;

            opacity:0;

}

#tooltip table{

            table-layout:fixed;

}

#tooltip tr td{

            padding:0;

            margin:0;

}

#tooltip tr td:nth-child(1){

            width:50px;

}

#tooltip tr td:nth-child(2){

            text-align:center;

}

 

Final product

 

Below is a screenshot of my finished widget. Now that we have the basic framework for a map widget, we can theoretically create a widget with any map and use any data from ServiceNow. The main part that we skipped for this post is the actual creation of the SVG map coordinates, but there are plenty of online resources that help with that process (potentially a future post?).

 

Post 3 Map.png

 

 

Sources

 

- https://d3js.org/

- US State Map

 

For a full collection of my posts, visit http://mitchstutler.com/blog

 

 

NOTE: MY POSTINGS REFLECT MY OWN VIEWS AND DO NOT NECESSARILY REPRESENT THE VIEWS OF MY EMPLOYER, ACCENTURE.

Filter Blog

By date: By tag: