From 281d558111c5094b828583109fe8417f94abfb1c Mon Sep 17 00:00:00 2001 From: toby Date: Tue, 30 May 2017 14:53:32 -0400 Subject: [PATCH] Add hex density chart --- src/core/Utils.js | 1 + src/core/config/OperationConfig.js | 39 ++++++ src/core/operations/Charts.js | 205 +++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100755 src/core/operations/Charts.js diff --git a/src/core/Utils.js b/src/core/Utils.js index 9b0d2a30..bb05ec3d 100755 --- a/src/core/Utils.js +++ b/src/core/Utils.js @@ -1021,6 +1021,7 @@ const Utils = { "Comma": ",", "Semi-colon": ";", "Colon": ":", + "Tab": "\t", "Line feed": "\n", "CRLF": "\r\n", "Forward slash": "/", diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index 5fd5a9ee..f11809ad 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -5,6 +5,7 @@ import Base64 from "../operations/Base64.js"; import BitwiseOp from "../operations/BitwiseOp.js"; import ByteRepr from "../operations/ByteRepr.js"; import CharEnc from "../operations/CharEnc.js"; +import Charts from "../operations/Charts.js"; import Checksum from "../operations/Checksum.js"; import Cipher from "../operations/Cipher.js"; import Code from "../operations/Code.js"; @@ -3388,6 +3389,44 @@ const OperationConfig = { } ] }, + "Hex Density chart": { + description: [].join("\n"), + run: Charts.runHexDensityChart, + inputType: "string", + outputType: "html", + args: [ + { + name: "Record delimiter", + type: "option", + value: Charts.RECORD_DELIMITER_OPTIONS, + }, + { + name: "Field delimiter", + type: "option", + value: Charts.FIELD_DELIMITER_OPTIONS, + }, + { + name: "Radius", + type: "number", + value: 25, + }, + { + name: "Use column headers as labels", + type: "boolean", + value: true, + }, + { + name: "X label", + type: "string", + value: "", + }, + { + name: "Y label", + type: "string", + value: "", + }, + ] + } }; export default OperationConfig; diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js new file mode 100755 index 00000000..a1ab9725 --- /dev/null +++ b/src/core/operations/Charts.js @@ -0,0 +1,205 @@ +import * as d3 from "d3"; +import {hexbin as d3hexbin} from "d3-hexbin"; +import Utils from "../Utils.js"; + +/** + * Charting operations. + * + * @author tlwr [toby@toby.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + * + * @namespace + */ +const Charts = { + /** + * @constant + * @default + */ + RECORD_DELIMITER_OPTIONS: ["Line feed", "CRLF"], + + + /** + * @constant + * @default + */ + FIELD_DELIMITER_OPTIONS: ["Space", "Comma", "Semi-colon", "Colon", "Tab"], + + + /** + * Gets values from input for a scatter plot. + * + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} + */ + _getScatterValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { + let headings; + const values = []; + + input + .split(recordDelimiter) + .forEach((row, rowIndex) => { + let split = row.split(fieldDelimiter); + + if (split.length !== 2) throw "Each row must have length 2."; + + if (columnHeadingsAreIncluded && rowIndex === 0) { + headings = {}; + headings.x = split[0]; + headings.y = split[1]; + } else { + let x = split[0], + y = split[1]; + + x = parseFloat(x, 10); + if (Number.isNaN(x)) throw "Values must be numbers in base 10."; + + y = parseFloat(y, 10); + if (Number.isNaN(y)) throw "Values must be numbers in base 10."; + + values.push([x, y]); + } + }); + + return { headings, values}; + }, + + + /** + * Hex Bin chart operation. + * + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + runHexDensityChart: function (input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + radius = args[2], + columnHeadingsAreIncluded = args[3], + dimension = 500; + + let xLabel = args[4], + yLabel = args[5], + { headings, values } = Charts._getScatterValues( + input, + recordDelimiter, + fieldDelimiter, + columnHeadingsAreIncluded + ); + + if (headings) { + xLabel = headings.x; + yLabel = headings.y; + } + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${dimension} ${dimension}`); + + let margin = { + top: 0, + right: 0, + bottom: 30, + left: 30, + }, + width = dimension - margin.left - margin.right, + height = dimension - margin.top - margin.bottom, + marginedSpace = svg.append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + let hexbin = d3hexbin() + .radius(radius) + .extent([0, 0], [width, height]); + + let hexPoints = hexbin(values), + maxCount = Math.max(...hexPoints.map(b => b.length)); + + let xExtent = d3.extent(hexPoints, d => d.x), + yExtent = d3.extent(hexPoints, d => d.y); + xExtent[0] -= 2 * radius; + xExtent[1] += 2 * radius; + yExtent[0] -= 2 * radius; + yExtent[1] += 2 * radius; + + let xAxis = d3.scaleLinear() + .domain(xExtent) + .range([0, width]); + let yAxis = d3.scaleLinear() + .domain(yExtent) + .range([height, 0]); + + let color = d3.scaleSequential(d3.interpolateLab("white", "steelblue")) + .domain([0, maxCount]); + + marginedSpace.append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height); + + marginedSpace.append("g") + .attr("class", "hexagon") + .attr("clip-path", "url(#clip)") + .selectAll("path") + .data(hexPoints) + .enter() + .append("path") + .attr("d", d => { + return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(radius * 0.75)}`; + }) + .attr("fill", (d) => color(d.length)) + .append("title") + .text(d => { + let count = d.length, + perc = 100.0 * d.length / values.length, + CX = d.x, + CY = d.y, + xMin = Math.min(...d.map(d => d[0])), + xMax = Math.max(...d.map(d => d[0])), + yMin = Math.min(...d.map(d => d[1])), + yMax = Math.max(...d.map(d => d[1])), + tooltip = `Count: ${count}\n + Percentage: ${perc.toFixed(2)}%\n + Center: ${CX.toFixed(2)}, ${CY.toFixed(2)}\n + Min X: ${xMin.toFixed(2)}\n + Max X: ${xMax.toFixed(2)}\n + Min Y: ${yMin.toFixed(2)}\n + Max Y: ${yMax.toFixed(2)} + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + + marginedSpace.append("g") + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); + + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("y", -margin.left) + .attr("x", -(height / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yLabel); + + marginedSpace.append("g") + .attr("class", "axis axis--x") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); + + svg.append("text") + .attr("x", width / 2) + .attr("y", dimension) + .style("text-anchor", "middle") + .text(xLabel); + + return svg._groups[0][0].outerHTML; + }, +}; + +export default Charts;