You can do this with d3.nest
and a bit of data wrangling if you calculate the summary first and integrate it with the nested data. It will also be easier if you make a function for adding td
elements (i.e. just turn the existing code into a function):
function addCells ( selection ) {// create a cell in each row for each column selection.selectAll('td') .data(function(row) { return columns.map(function(column) { return { column: getHeaderWithColumn(column), value: row[column], }; }); }) .enter() .append('td') .style("color", function(d) { if (d.column === 'PM') { return pmColorScale(d.value); } if (d.column === 'Profit') { if (d.value < 0) { return "red"; } } }).html(function(d) { percentFormatter = d3.format(".0%"); dollarFormatter = d3.format("$,"); if (d.column === 'PM') { if (!isNaN(d.value)) { if (isNaN(d.value)) { d.value === Number.parseInt(0); } return percentFormatter(d.value / 100); } } if (d.column === 'Spend' || d.column === 'Revenue' || d.column === 'CPC' || d.column === 'RPC' || d.column === 'RPA' || d.column === 'Profit') { if (!isNaN(d.value)) { return dollarFormatter(d.value); } } return d.value; });}
Tables can have any number of tbody
elements, so we can take advantage of that and add a separate tbody
for each set of rows representing an affiliate.
First, calculate the summary:
const summary = merged.reduce(function(val, acc) { if (!val[acc.affiliateId]) val[acc.affiliateId] = { affiliateId: acc.affiliateId, Spend: 0, revenue: 0, profit: 0, profitMargin: 0, Clicks: 0, Conversions: 0 }; val[acc.affiliateId].Clicks += Number.parseFloat(acc.Clicks); val[acc.affiliateId].Conversions += Number.parseFloat(acc.Conversions); val[acc.affiliateId].Spend += Number.parseFloat(acc.Spend); val[acc.affiliateId].revenue += Number.parseFloat(acc.revenue); val[acc.affiliateId].profit += Number.parseFloat(acc.profit); val[acc.affiliateId].Campaign_Name = acc.Campaign_Name; val[acc.affiliateId].affiliate = acc.affiliate; val[acc.affiliateId].advertiser = acc.advertiser; return val;}, {});
Nest the data using the affiliateId
as the key, and integrate the summary
data into the nested data:
const nested = d3.nest().key( d => d.affiliateId ).entries(merged).map( d => { d.header = summary[d.key]; return d } );
nested
is now an array with entries that look like this:
{key: "6480", values: [Array], // rows with affiliateId 6480 header: Object // collated data on 6480 from `summary`}
Bind that to the table and add a tbody
for each entry:
var tbody = table1.selectAll('tbody') .data(nested) .enter() .append('tbody');
Add rows for the summary data by taking the header from the bound data. Note that d3 needs data to be in an array, so we return the header data as a single-element array. Give the row a class to distinguish it from the monthly data that we'll add next.
var summaryRow = tbody .selectAll('tr.summary') .data(function(d) { return [d.header] }) .enter() .append('tr') .classed('summary',true)
Add the td
elements for the row:
addCells(summary)
Now you can do the same with the rows for the monthly datasets, which d3.nest
has put in d.values
. Add the rows and then add the cells to the rows:
var rows = tbody.selectAll('tr.entry') .data(d => { return d.values }) .enter() .append('tr') .classed('entry', true)addCells(rows);
Full demo with some fake data:
function go() {const merged = [{"date": "2018-10-09","Campaign_Name": "Foo - 6480_1925","affiliateId": "6480","Clicks": 6,"Conversions": 0,"Spend": 0.5019512028,"affiliate": "Y_Foo_6480","revenue": 58.22,"advertiser": "sky","spend": 0.5,"profit": 57.72,"profitMargin": "99","cpc": 0.08,"rpc": 9.7,"rpa": ""}, {"date": "2018-09-09","Campaign_Name": "Foo - 6480_1925","affiliateId": "6480","Clicks": 6,"Conversions": 0,"Spend": 0.5019512028,"affiliate": "Y_Foo_6480","revenue": 58.22,"advertiser": "sky","spend": 0.5,"profit": 57.72,"profitMargin": "99","cpc": 0.08,"rpc": 9.7,"rpa": ""}, {"date": "2018-08-09","Campaign_Name": "Foo - 6480_1925","affiliateId": "6480","Clicks": 6,"Conversions": 0,"Spend": 0.5019512028,"affiliate": "Y_Foo_6480","revenue": 58.22,"advertiser": "sky","spend": 0.5,"profit": 57.72,"profitMargin": "99","cpc": 0.08,"rpc": 9.7,"rpa": ""}, {"date": "2018-07-09","Campaign_Name": "Foo - 6480_1925","affiliateId": "6480","Clicks": 6,"Conversions": 0,"Spend": 0.5019512028,"affiliate": "Y_Foo_6480","revenue": 58.22,"advertiser": "sky","spend": 0.5,"profit": 57.72,"profitMargin": "99","cpc": 0.08,"rpc": 9.7,"rpa": ""}, {"date": "2018-10-09","Campaign_Name": "Bar Mutual - 7157_2020","affiliateId": "7157","Clicks": 583,"Conversions": 0,"Spend": 166.0008698087,"affiliate": "Y_GetStuff_7191","revenue": 2.22,"advertiser": "Bar Mutual Insurance","spend": 166,"profit": -163.78,"profitMargin": "-7378","cpc": 0.28,"rpc": 0,"rpa": ""}, {"date": "2018-09-09","Campaign_Name": "Bar Mutual - 7157_2020","affiliateId": "7157","Clicks": 1,"Conversions": 0,"Spend": 0.0108815003,"affiliate": "Y_GetStuff_7191","revenue": "","advertiser": "Acme, Inc. ","spend": 0.01,"profit": -0.01,"cpc": 0.01,"rpc": 0,"rpa": ""}, {"date": "2018-08-09","Campaign_Name": "Bar Mutual - 7157_2020","affiliateId": "7157","Clicks": 6,"Conversions": 0,"Spend": 1.3499999642,"affiliate": "Y_GetStuff_7191","revenue": 0.36,"advertiser": "Art","spend": 1.35,"profit": -0.99,"profitMargin": "-275","cpc": 0.22,"rpc": 0.06,"rpa": ""}, {"date": "2018-07-09","Campaign_Name": "Bar Mutual - 7157_2020","affiliateId": "7157","Clicks": 199,"Conversions": 0,"Spend": 10.2255493868,"affiliate": "Y_GetStuff_7191","revenue": "","advertiser": "Acme, Inc. ","spend": 10.23,"profit": -10.23,"cpc": 0.06,"rpc": 0,"rpa": ""}, {"date": "2018-10-09","Campaign_Name": "test - NS - New Cars - 4735_2092","affiliateId": "4735","Clicks": 200,"Conversions": 34,"Spend": 59.1212777495,"affiliate": "Y_Mobile-3B_OMNewCar_4735","revenue": 20.1,"advertiser": "Acme, Inc. ","spend": 59.12,"profit": -39.02,"profitMargin": "-194","cpc": 0.3,"rpc": 0.1,"rpa": 0.59}, {"date": "2018-10-09","Campaign_Name": "test - NS - New Cars - 6586_2092","affiliateId": "6586","Clicks": 472,"Conversions": 79,"Spend": 61.0002093334,"affiliate": "Y_New Cars_6586","revenue": 0.75,"advertiser": "Acme, Inc. ","spend": 61,"profit": -60.25,"profitMargin": "-8033","cpc": 0.13,"rpc": 0,"rpa": 0.01}, {"date": "2018-10-09","Campaign_Name": "test - NS - New Cars - 6618_2092","affiliateId": "6618","Clicks": 2,"Conversions": 1,"Spend": 0.2018772066,"affiliate": "Y_New Cars_6618","revenue": "","advertiser": "Acme, Inc. ","spend": 0.2,"profit": -0.2,"cpc": 0.1,"rpc": 0,"rpa": 0}, {"date": "2018-10-09","Campaign_Name": "test - NS - New Cars - 7247_1773","affiliateId": "7247","Clicks": 76,"Conversions": 7,"Spend": 13.9912065665,"affiliate": "Y_New Cars_7247","revenue": "","advertiser": "Acme, Inc. ","spend": 13.99,"profit": -13.99,"cpc": 0.18,"rpc": 0,"rpa": 0}, {"date": "2018-10-09","Campaign_Name": "test - NS - New Cars - NSConvLAL - 6594_2092","affiliateId": "6594","Clicks": 905,"Conversions": 264,"Spend": 293.5172631741,"affiliate": "Y_New Cars_6594","revenue": 1.72,"advertiser": "Acme, Inc. ","spend": 293.64,"profit": -291.8,"profitMargin": "-16965","cpc": 0.32,"rpc": 0,"rpa": 0.01}, {"date": "2018-10-09","Campaign_Name": "test - NS - New Cars - NSConvLAL - 7251_2092","affiliateId": "7251","Clicks": 202,"Conversions": 1,"Spend": 64.9944748056,"affiliate": "Y_New Cars_7251","revenue": "","advertiser": "Acme, Inc. ","spend": 64.99,"profit": -64.99,"cpc": 0.26,"rpc": 0,"rpa": 0}, {"date": "2018-10-09","Campaign_Name": "test - NS - New Cars - Span/Eng - 7165_1773","affiliateId": "7165","Clicks": 891,"Conversions": 49,"Spend": 74.5347691271,"affiliate": "Y_New Cars_7165","revenue": "","advertiser": "Acme, Inc. ","spend": 74.53,"profit": -74.53,"cpc": 0.08,"rpc": 0,"rpa": 0}, {"date": "2018-10-09","Campaign_Name": "test - New Cars - 4713_1875","affiliateId": "4713","Clicks": 1084,"Conversions": 326,"Spend": 64.7100853845,"affiliate": "Y_New Cars_4713","revenue": "","advertiser": "Umbrella","spend": 64.71,"profit": -64.71,"cpc": 0.05,"rpc": 0,"rpa": 0}, {"date": "2018-10-09","Campaign_Name": "test - New Cars - 7259_1875","affiliateId": "7259","Clicks": 1568,"Conversions": 173,"Spend": 51.5844874121,"affiliate": "Y_New Cars_7259","revenue": "","advertiser": "Umbrella","spend": 51.58,"profit": -51.58,"cpc": 0.03,"rpc": 0,"rpa": 0}, {"date": "2018-10-09","Campaign_Name": "test - Destination - 7221_2068","affiliateId": "7221","Clicks": 75,"Conversions": 0,"Spend": 4.9945735649,"affiliate": "Y_Destination_7221","revenue": 1.5,"advertiser": "L-health","spend": 4.99,"profit": -3.17,"profitMargin": "-212","cpc": 0.06,"rpc": 0.02,"rpa": ""}, {"date": "2018-10-09","Campaign_Name": "test - Product - 7243_1791","affiliateId": "7243","Clicks": 36,"Conversions": 0,"Spend": 1.201965495,"affiliate": "Y_Product_7243","revenue": 0.07,"advertiser": "Product Tubs","spend": 1.2,"profit": -1.13,"profitMargin": "-1617","cpc": 0.03,"rpc": 0,"rpa": ""}, {"date": "2018-10-09","Campaign_Name": "test - Homewares - 7269_2163","affiliateId": "7269","Clicks": 11,"Conversions": 0,"Spend": 0.5186665021,"affiliate": "Y_Homewares_7269","revenue": "","advertiser": "Acme, Inc. ","spend": 0.64,"profit": -0.64,"cpc": 0.05,"rpc": 0,"rpa": ""}]const columnHeaderMap = { Date: "date", AffiliateId: "affiliateId", Spend: "spend", Revenue: "revenue", CPC: "cpc", RPC: "rpc", RPA: "rpa", Profit: "profit", PM: "profitMargin", Campaign: "Campaign_Name", Affiliate: "affiliate"};const headers = Object.keys(columnHeaderMap);const columns = headers.map(header => columnHeaderMap[header]);const getHeaderWithColumn = column => { for (let header in columnHeaderMap) { if (columnHeaderMap[header] === column) { return header; } }};var pmColorScale = d3.scaleThreshold() .domain([0, 20]) .range(['red', '#FDE541', 'green']);// // setup the area for the table// d3.selectAll('table').data([0]).enter().append('table');var table1 = d3.select('#table');table1.selectAll('thead').data([0]).enter().append('thead');var thead = table1.select('thead');// // append the header rowthead.append('tr') .selectAll('th') .data(headers) .enter() .append('th') .text(function(column) { return column; }) .on('click', function(d) { thead.attr('class', 'header'); const columnName = columnHeaderMap[d]; if (sortAscending) { rows.sort((a, b) => { if (d === 'PM') { if (isNaN(a.profitMargin)) { return a.profitMargin == 0; } if (isNaN(b.profitMargin)) { return b.profitMargin == 0; } a.profitMargin = Number.parseFloat(a.profitMargin); b.profitMargin = Number.parseFloat(b.profitMargin); // parse the string into a float // then do the sort calc } return b[columnHeaderMap[d]] < a[columnHeaderMap[d]] ? 1 : -1; }); sortAscending = false; } else { rows.sort((a, b) => { if (d === 'PM') { if (isNaN(a.profitMargin)) { return a.profitMargin == 0; } if (isNaN(b.profitMargin)) { return b.profitMargin == 0; } a.profitMargin = Number.parseFloat(a.profitMargin); b.profitMargin = Number.parseFloat(b.profitMargin); // parse the string into a float // then do the sort calc } return b[columnHeaderMap[d]] > a[columnHeaderMap[d]] ? 1 : -1; }); sortAscending = true; } });// Time to make the summary// // This is a subtotal reducer so each id has its totalconst summary = merged.reduce(function(val, acc) { if (!val[acc.affiliateId]) val[acc.affiliateId] = { affiliateId: acc.affiliateId, Spend: 0, revenue: 0, profit: 0, profitMargin: 0, Clicks: 0, Conversions: 0 }; val[acc.affiliateId].Clicks += Number.parseFloat(acc.Clicks); val[acc.affiliateId].Conversions += Number.parseFloat(acc.Conversions); val[acc.affiliateId].Spend += Number.parseFloat(acc.Spend); val[acc.affiliateId].revenue += Number.parseFloat(acc.revenue); val[acc.affiliateId].profit += Number.parseFloat(acc.profit); val[acc.affiliateId].Campaign_Name = acc.Campaign_Name; val[acc.affiliateId].affiliate = acc.affiliate; val[acc.affiliateId].advertiser = acc.advertiser; return val;}, {});const nested = d3.nest().key( d => d.affiliateId ).entries(merged).map( d => { d.header = summary[d.key]; return d } );var tbody = table1.selectAll('tbody') .data(nested) .enter() .append('tbody');var summaryRow = tbody .selectAll('tr.summary') .data(d => [d.header]) .enter() .append('tr') .classed('summary',true)addCells(summaryRow)// create a row for each object in the datavar rows = tbody.selectAll('tr.entry') .data(d => { return d.values }) .enter() .append('tr') .classed('entry', true)addCells(rows);function addCells ( selection ) {// create a cell in each row for each column selection.selectAll('td') .data(function(row) { return columns.map(function(column) { return { column: getHeaderWithColumn(column), value: row[column], }; }); }) .enter() .append('td') .style("color", function(d) { if (d.column === 'PM') { return pmColorScale(d.value); } if (d.column === 'Profit') { if (d.value < 0) { return "red"; } } }).html(function(d) { percentFormatter = d3.format(".0%"); dollarFormatter = d3.format("$,"); if (d.column === 'PM') { if (!isNaN(d.value)) { if (isNaN(d.value)) { d.value === Number.parseInt(0); } return percentFormatter(d.value / 100); } } if (d.column === 'Spend' || d.column === 'Revenue' || d.column === 'CPC' || d.column === 'RPC' || d.column === 'RPA' || d.column === 'Profit') { if (!isNaN(d.value)) { return dollarFormatter(d.value); } } return d.value; });}function sort(a, b) { if (typeof a == "string") { var parseA = format.parse(a); if (parseA) { var dateA = parseA.getDate(); var dateB = format.parse(b).getDate(); return dateA > dateB ? 1 : dateA == dateB ? 0 : -1; } else return a.localeCompare(b); } else if (typeof a == "number") { return a > b ? 1 : a == b ? 0 : -1; } else if (typeof a == "boolean") { return b ? 1 : a ? -1 : 0; }}}window.onload = go;
.summary td { font-weight: bold; background-color: aliceblue; }
<script src="http://d3js.org/d3.v5.js"></script> <table id="table"></table>