From a095190d8a5ced92a69bff843ad66e0c0f8e0832 Mon Sep 17 00:00:00 2001 From: Simon Arnell Date: Fri, 15 May 2020 23:49:16 +0100 Subject: [PATCH] added dpia results functionality --- README.md | 2 + resources/css/results.css | 21 +++++ resources/js/charts.js | 169 ++++++++++++++++++++++++++++++++++++++ resources/js/results.js | 113 +++++++++++++++++++++++++ resources/js/table.js | 22 +++++ results.html | 52 ++++++++++++ sitemap.xml | 3 + 7 files changed, 382 insertions(+) create mode 100644 resources/css/results.css create mode 100644 resources/js/charts.js create mode 100644 resources/js/results.js create mode 100644 resources/js/table.js create mode 100644 results.html diff --git a/README.md b/README.md index bbf1df6..4e0163d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ A free web-based Data Protection Impact Assessment Tool to assist organisations An instance of the tool is hosted on [GitHub Pages](https://simonarnell.github.io/GDPRDPIAT/) for preview. Please note, to demonstrate how one might use this project for self-service data protection impact assessments within a DevOps team, this project uses the [Staticman](https://staticman.net) project, a useful tool for static sites such as GitHub pages that allows user generated content, in our case GDPR DPIAs, to be committed into a GitHub repository, for this project the submissions are committed on a branch called [staticman](https://github.com/simonarnell/GDPRDPIAT/tree/staticman). The data protection impact assessments could then be used within a GitOps workflow to allow a security expert within the wider DevSecOps team to provide more in-depth analysis and a set of recommendations for a project or sprint. +The submitted DPIAs can be reviewed on the [results](https://simonarnell.github.io/GDPRDPIAT/results.html) page. This queries the GitHub API for the contents of the staticman branch of this repository, the DPIAs are then collated and charts dynamically generated for analysis. + ## Disclaimer Please use this only for what it is intended, a first pass assessment, seek separate legal and privacy advice for a more formal assessment of your organisation’s position. **I accept no liability.** diff --git a/resources/css/results.css b/resources/css/results.css new file mode 100644 index 0000000..8cdbf74 --- /dev/null +++ b/resources/css/results.css @@ -0,0 +1,21 @@ +article { + padding-left: 10px; + padding-right: 10px; +} + +.tick { + color: black; +} + +.response { + fill: #003399; +} + +.legend.swatch { + stroke: white; + stroke-width: 2; +} + +.legend.label { + text-anchor: middle; +} diff --git a/resources/js/charts.js b/resources/js/charts.js new file mode 100644 index 0000000..d54faa3 --- /dev/null +++ b/resources/js/charts.js @@ -0,0 +1,169 @@ +export class Chart { + constructor(config, data) { + this.config = config + this.data = data + return this + } + + generateXScale(accessor) { + this.xScale = d3.scaleBand() + .domain(this.data.responseSet.choices.map(accessor)) + .range([this.config.margin.left, this.config.width - this.config.margin.right]) + .paddingOuter(this.config.padding.outer) + .paddingInner(this.config.padding.inner) + } + + generateXLabels() { + d3.select(`#${this.data.name}`) + .append('svg') + .attr('viewBox', [0, 0, this.config.width, this.config.height]) + .append('g').attr('class', 'x ticks') + .call(d3.axisBottom(this.xScale)) + .selectAll('.tick text') + .call(this.wrap, this.xScale.bandwidth()) + this.config.margin.bottom += d3.max(d3.select(`#${this.data.name}`).selectAll('.tick text').nodes(), (text) => text.getBBox().height) + } + + wrap(text, width) { + text.each(function() { + var text = d3.select(this), + words = text.text().split(/\s+/).reverse(), + word, + line = [], + lineNumber = 0, + lineHeight = 1.1, + y = text.attr('y'), + dy = parseFloat(text.attr('dy')), + tspan = text.text(null).append('tspan').attr('x', 0).attr('y', y).attr('dy', dy + 'em'); + while (word = words.pop()) { + line.push(word); + tspan.text(line.join(' ')); + if (tspan.node().getComputedTextLength() > width) { + line.pop(); + tspan.text(line.join(' ')); + line = [word]; + tspan = text.append('tspan').attr('x', 0).attr('y', y).attr('dy', ++lineNumber * lineHeight + dy +'em').text(word); + } + } + }); + } +} + +export class BarChart extends Chart { + constructor(config, data) { + super(config, data) + this.generateXScale(d => Object.keys(d)[0]) + this.generateXLabels() + this.yScale = d3.scaleLinear() + .domain([0, this.data.responseSet.choices.reduce((acc, choice) => Object.values(choice)[0] > acc ? Object.values(choice)[0] : acc, 0)]).nice() + .range([this.config.height - this.config.margin.bottom, this.config.margin.top]) + d3.select(`#${this.data.name}`) + .select('svg') + .call(svg => svg + .append('g') + .attr('class', 'axes') + .call(g => g + .append('g') + .attr('class', 'axis x') + .attr('transform', `translate(0, ${this.config.height - this.config.margin.bottom})`) + .append(() => d3.select(`#${this.data.name}`).select('.x.ticks').remove().node())) + .call(g => g + .append('g') + .attr('class', 'axis y') + .attr('transform', `translate(${this.config.margin.left}, 0)`) + .append('g').attr('class', 'y ticks').call(d3.axisLeft(this.yScale)))) + .call(svg => svg + .append('g') + .attr('class', 'responses') + .selectAll('rect') + .data(this.data.responseSet.choices) + .join('rect') + .attr('class', 'response') + .attr('width', this.xScale.bandwidth()) + .attr('height', d => this.yScale.range()[0] - this.yScale(Object.values(d)[0])) + .attr('x', d => this.xScale(Object.keys(d)[0])) + .attr('y', d => this.yScale(Object.values(d)[0]))) + return this + } +} + +export class StackedBarChart extends Chart { + constructor(config, data) { + super(config, data) + + this.generateXScale(d => Object.values(d)[0]) + this.generateXLabels() + + let keys = [...new Set(this.data.responseSet.choices.map(({ question, ...responses}) => { return { ...responses }}) + .reduce((acc, responses) => acc = acc.concat(Object.keys(responses)),[]))] + + this.colour = d3.scaleOrdinal() + .domain(keys.map((d, i)=> i)) + .range(['#003399','#ffcc00']) + + d3.select(`#${this.data.name}`).select('svg') + .append('g') + .attr('class', 'legend') + .attr('transform', `translate(${this.config.width - this.config.margin.right - this.config.legend.width}, ${this.config.margin.top})`) + .selectAll('g') + .data(this.colour.domain()) + .join('g') + .attr('transform', (d, i, arr) => `translate(${i * this.config.legend.width / arr.length}, 0)`) + .call(g => g + .append('rect') + .attr('class', 'legend swatch') + .attr('height', this.config.legend.height) + .attr('width', (d, i, arr) => this.config.legend.width / arr.length) + .style('fill', d => this.colour(d))) + .call(g => g + .append('text') + .attr('transform', `translate(0, ${this.config.legend.height})`) + .attr('class', 'legend label') + .attr('x', (d, i, arr) => this.config.legend.width / arr.length / 2) + .attr('y', '1em') + .text(d => keys[d])) + + this.config.margin.top += d3.select('.legend').node().getBBox().height + + let stack = d3.stack() + .keys(keys) + .order(d3.stackOrderNone) + .offset(d3.stackOffsetNone) + + let series = stack(this.data.responseSet.choices); + + this.yScale = d3.scaleLinear() + .domain([0, d3.max(series, d => d3.max(d, d => d[1]))]).nice() + .range([this.config.height - this.config.margin.bottom, this.config.margin.top]) + + d3.select(`#${this.data.name}`) + .select('svg') + .call(svg => svg + .append('g') + .attr('class', 'axes') + .call(g => g + .append('g') + .attr('class', 'axis x') + .attr('transform', `translate(0, ${this.config.height - this.config.margin.bottom})`) + .append(() => d3.select(`#${this.data.name}`).select('.x.ticks').remove().node())) + .call(g => g + .append('g') + .attr('class', 'axis y') + .attr('transform', `translate(${this.config.margin.left}, 0)`) + .append('g').attr('class', 'y ticks').call(d3.axisLeft(this.yScale)))) + .call(svg => svg + .append('g') + .selectAll('g') + .data(series) + .join('g') + .style('fill', (d, i) => this.colour(i)) + .selectAll('rect') + .data(d => d) + .join('rect') + .attr('x', (d, i) => this.xScale(this.data.responseSet.choices[i].question)) + .attr('y', d => this.yScale(d[1])) + .attr('height', d => this.yScale(d[0]) - this.yScale(d[1])) + .attr('width', this.xScale.bandwidth())) + return this + } +} diff --git a/resources/js/results.js b/resources/js/results.js new file mode 100644 index 0000000..8c702e2 --- /dev/null +++ b/resources/js/results.js @@ -0,0 +1,113 @@ +import { BarChart, StackedBarChart } from './charts.js'; +import { Table } from './table.js'; + +var questionsFetch = fetch('resources/data/questions.json') + .then(res => res.json()) + +var responsesFetch = fetch('https://api.github.com/repos/simonarnell/GDPRDPIAT/contents/_data/dpia?ref=staticman') + .then(res => res.json()) + .then(json => Promise.all(json.map(dpia => { + return fetch(dpia.download_url) + .then(res => res.json()) + }))) + +var questions = [], responses = []; +Promise.all([questionsFetch, responsesFetch]).then(vals => { + vals[0].pages.map(page => page.questions.map(question => { + questions.push(question) + responses[question.name] = { + ...(()=>{ + let choices = {} + if(question.choices !== undefined) { + choices = question.choices.reduce((acc, choice) => { + acc[choice.value] = 0 + return acc + }, {}) + } else if(question.type == 'matrix') { + choices = question.rows.reduce((acc, row) => { + acc[row.value] = question.columns.reduce((acc, column) => { + acc[column.value] = 0 + return acc + }, {}) + return acc + }, {}) + } + return choices + })(), + ...vals[1].map(response => { + let formattedResponse = []; + if(response[question.name] !== undefined) { + if(question.type == 'checkbox') formattedResponse = response[question.name].split(',') + else formattedResponse = [response[question.name]] + } else if(question.type == 'matrix') formattedResponse = question.rows.map(row => response[question.name + String.fromCharCode(97 + (row.value - 1))]) + return formattedResponse + }).reduce((acc, responses, idx) => { + if(idx == 0 && question.type == 'matrix') + acc = question.rows.reduce((acc, row) => { + acc[row.value] = question.columns.reduce((acc, column) => { + acc[column.value] = 0 + return acc + }, {}) + return acc + }, {}) + let countedResponse = responses.reduce((acc, response, idx) => { + if(question.type == 'matrix') { + if(response == undefined) return acc + if(!acc[idx+1]) acc[idx+1] = {}; + acc[idx+1][response] = !!acc[idx+1][response] ? acc[idx+1][response] + 1 : 1 + } else acc[response] = !!acc[response] ? acc[response] + 1 : 1 + return acc + }, {}) + Object.keys(countedResponse).forEach(item => { + if(question.type == 'matrix') { + if(!acc[item]) acc[item] = {} + Object.keys(countedResponse[item]).forEach(response => acc[item][response] = !!acc[item] ? acc[item][response] + countedResponse[item][response] : 1) + } else acc[item] = !!acc[item] ? acc[item] + countedResponse[item] : 1 + }) + return acc + }, {}) + } + })) + questions.map(({choices, columns, name, rows, title, type}) => { + let config = { + height: 540, + width: 1920, + margin: { + top: 10, + left: 20, + right: 10, + bottom: 10 + }, + padding: { + inner: 0.03, + outer: 0.03 + }, + legend: { + width: 240, + height: 50 + } + } + let responseSet = {} + if(type == 'matrix') { + responseSet.choices = rows.map(row => { + return { + question: row.text, + ...columns.reduce((acc, column) => { + acc[column.text] = responses[name][row.value][column.value] + return acc + }, {}) + } + }) + } else if (choices !== undefined) { + responseSet.choices = choices.map(choice => { + return { [choice.text]: responses[name][choice.value] } + }) + } else return + let section = document.createElement('section') + section.id = name + document.getElementById('results').appendChild(section) + new Table({ name, title }) + if(type == 'matrix') new StackedBarChart(config, { name, type, responseSet }) + else new BarChart(config, { name, type, responseSet }) + }) +}) diff --git a/resources/js/table.js b/resources/js/table.js new file mode 100644 index 0000000..664b8ee --- /dev/null +++ b/resources/js/table.js @@ -0,0 +1,22 @@ +export class Table { + constructor(data) { + this.data = data; + let table = document.createElement('table') + let tr = document.createElement('tr') + let td = document.createElement('td') + let h3 = document.createElement('h3') + let match = this.data.name.match(/(question)(([0-9])+[a-zA-Z]*)/) + h3.textContent = `${match[1].slice(0,1).toUpperCase()}${match[1].slice(1, match[1].length)} ${match[2]}` + td.appendChild(h3) + tr.appendChild(td) + table.appendChild(tr) + tr = document.createElement('tr') + td = document.createElement('td') + let h4 = document.createElement('h4') + h4.textContent = this.data.title + td.appendChild(h4) + tr.appendChild(td) + table.appendChild(tr) + document.getElementById(this.data.name).appendChild(table) + } +} diff --git a/results.html b/results.html new file mode 100644 index 0000000..7f10fbd --- /dev/null +++ b/results.html @@ -0,0 +1,52 @@ + + + + General Data Protection Regulation - Data Protection Impact Assessment Tool - Results + + + + + + + + + + +
+ Fork me on GitHub +

EU General Data Protection Regulation (GDPR) Data Protection Impact Assessment (DPIA) Tool

+
+
+
+

Results

+
+
+ + + diff --git a/sitemap.xml b/sitemap.xml index d9491d3..795a2d4 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -7,4 +7,7 @@ https://simonarnell.github.io/GDPRDPIAT/ + + https://simonarnell.github.io/GDPRDPIAT/results.html +