HexDensityChart.mjs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. /**
  2. * @author tlwr [toby@toby.codes]
  3. * @copyright Crown Copyright 2019
  4. * @license Apache-2.0
  5. */
  6. import * as d3 from "d3";
  7. import * as d3hexbin from "d3-hexbin";
  8. import * as nodom from "nodom";
  9. import { getScatterValues, RECORD_DELIMITER_OPTIONS, COLOURS, FIELD_DELIMITER_OPTIONS } from "../lib/Charts";
  10. import Operation from "../Operation";
  11. import Utils from "../Utils";
  12. /**
  13. * Hex Density chart operation
  14. */
  15. class HexDensityChart extends Operation {
  16. /**
  17. * HexDensityChart constructor
  18. */
  19. constructor() {
  20. super();
  21. this.name = "Hex Density chart";
  22. this.module = "Charts";
  23. this.description = "Hex density charts are used in a similar way to scatter charts, however rather than rendering tens of thousands of points, it groups the points into a few hundred hexagons to show the distribution.";
  24. this.inputType = "string";
  25. this.outputType = "html";
  26. this.args = [
  27. {
  28. name: "Record delimiter",
  29. type: "option",
  30. value: RECORD_DELIMITER_OPTIONS,
  31. },
  32. {
  33. name: "Field delimiter",
  34. type: "option",
  35. value: FIELD_DELIMITER_OPTIONS,
  36. },
  37. {
  38. name: "Pack radius",
  39. type: "number",
  40. value: 25,
  41. },
  42. {
  43. name: "Draw radius",
  44. type: "number",
  45. value: 15,
  46. },
  47. {
  48. name: "Use column headers as labels",
  49. type: "boolean",
  50. value: true,
  51. },
  52. {
  53. name: "X label",
  54. type: "string",
  55. value: "",
  56. },
  57. {
  58. name: "Y label",
  59. type: "string",
  60. value: "",
  61. },
  62. {
  63. name: "Draw hexagon edges",
  64. type: "boolean",
  65. value: false,
  66. },
  67. {
  68. name: "Min colour value",
  69. type: "string",
  70. value: COLOURS.min,
  71. },
  72. {
  73. name: "Max colour value",
  74. type: "string",
  75. value: COLOURS.max,
  76. },
  77. {
  78. name: "Draw empty hexagons within data boundaries",
  79. type: "boolean",
  80. value: false,
  81. }
  82. ];
  83. }
  84. /**
  85. * Hex Bin chart operation.
  86. *
  87. * @param {string} input
  88. * @param {Object[]} args
  89. * @returns {html}
  90. */
  91. run(input, args) {
  92. const recordDelimiter = Utils.charRep(args[0]),
  93. fieldDelimiter = Utils.charRep(args[1]),
  94. packRadius = args[2],
  95. drawRadius = args[3],
  96. columnHeadingsAreIncluded = args[4],
  97. drawEdges = args[7],
  98. minColour = args[8],
  99. maxColour = args[9],
  100. drawEmptyHexagons = args[10],
  101. dimension = 500;
  102. let xLabel = args[5],
  103. yLabel = args[6];
  104. const { headings, values } = getScatterValues(
  105. input,
  106. recordDelimiter,
  107. fieldDelimiter,
  108. columnHeadingsAreIncluded
  109. );
  110. if (headings) {
  111. xLabel = headings.x;
  112. yLabel = headings.y;
  113. }
  114. const document = new nodom.Document();
  115. let svg = document.createElement("svg");
  116. svg = d3.select(svg)
  117. .attr("width", "100%")
  118. .attr("height", "100%")
  119. .attr("viewBox", `0 0 ${dimension} ${dimension}`);
  120. const margin = {
  121. top: 10,
  122. right: 0,
  123. bottom: 40,
  124. left: 30,
  125. },
  126. width = dimension - margin.left - margin.right,
  127. height = dimension - margin.top - margin.bottom,
  128. marginedSpace = svg.append("g")
  129. .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
  130. const hexbin = d3hexbin.hexbin()
  131. .radius(packRadius)
  132. .extent([0, 0], [width, height]);
  133. const hexPoints = hexbin(values),
  134. maxCount = Math.max(...hexPoints.map(b => b.length));
  135. const xExtent = d3.extent(hexPoints, d => d.x),
  136. yExtent = d3.extent(hexPoints, d => d.y);
  137. xExtent[0] -= 2 * packRadius;
  138. xExtent[1] += 3 * packRadius;
  139. yExtent[0] -= 2 * packRadius;
  140. yExtent[1] += 2 * packRadius;
  141. const xAxis = d3.scaleLinear()
  142. .domain(xExtent)
  143. .range([0, width]);
  144. const yAxis = d3.scaleLinear()
  145. .domain(yExtent)
  146. .range([height, 0]);
  147. const colour = d3.scaleSequential(d3.interpolateLab(minColour, maxColour))
  148. .domain([0, maxCount]);
  149. marginedSpace.append("clipPath")
  150. .attr("id", "clip")
  151. .append("rect")
  152. .attr("width", width)
  153. .attr("height", height);
  154. if (drawEmptyHexagons) {
  155. marginedSpace.append("g")
  156. .attr("class", "empty-hexagon")
  157. .selectAll("path")
  158. .data(this.getEmptyHexagons(hexPoints, packRadius))
  159. .enter()
  160. .append("path")
  161. .attr("d", d => {
  162. return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`;
  163. })
  164. .attr("fill", (d) => colour(0))
  165. .attr("stroke", drawEdges ? "black" : "none")
  166. .attr("stroke-width", drawEdges ? "0.5" : "none")
  167. .append("title")
  168. .text(d => {
  169. const count = 0,
  170. perc = 0,
  171. tooltip = `Count: ${count}\n
  172. Percentage: ${perc.toFixed(2)}%\n
  173. Center: ${d.x.toFixed(2)}, ${d.y.toFixed(2)}\n
  174. `.replace(/\s{2,}/g, "\n");
  175. return tooltip;
  176. });
  177. }
  178. marginedSpace.append("g")
  179. .attr("class", "hexagon")
  180. .attr("clip-path", "url(#clip)")
  181. .selectAll("path")
  182. .data(hexPoints)
  183. .enter()
  184. .append("path")
  185. .attr("d", d => {
  186. return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`;
  187. })
  188. .attr("fill", (d) => colour(d.length))
  189. .attr("stroke", drawEdges ? "black" : "none")
  190. .attr("stroke-width", drawEdges ? "0.5" : "none")
  191. .append("title")
  192. .text(d => {
  193. const count = d.length,
  194. perc = 100.0 * d.length / values.length,
  195. CX = d.x,
  196. CY = d.y,
  197. xMin = Math.min(...d.map(d => d[0])),
  198. xMax = Math.max(...d.map(d => d[0])),
  199. yMin = Math.min(...d.map(d => d[1])),
  200. yMax = Math.max(...d.map(d => d[1])),
  201. tooltip = `Count: ${count}\n
  202. Percentage: ${perc.toFixed(2)}%\n
  203. Center: ${CX.toFixed(2)}, ${CY.toFixed(2)}\n
  204. Min X: ${xMin.toFixed(2)}\n
  205. Max X: ${xMax.toFixed(2)}\n
  206. Min Y: ${yMin.toFixed(2)}\n
  207. Max Y: ${yMax.toFixed(2)}
  208. `.replace(/\s{2,}/g, "\n");
  209. return tooltip;
  210. });
  211. marginedSpace.append("g")
  212. .attr("class", "axis axis--y")
  213. .call(d3.axisLeft(yAxis).tickSizeOuter(-width));
  214. svg.append("text")
  215. .attr("transform", "rotate(-90)")
  216. .attr("y", -margin.left)
  217. .attr("x", -(height / 2))
  218. .attr("dy", "1em")
  219. .style("text-anchor", "middle")
  220. .text(yLabel);
  221. marginedSpace.append("g")
  222. .attr("class", "axis axis--x")
  223. .attr("transform", "translate(0," + height + ")")
  224. .call(d3.axisBottom(xAxis).tickSizeOuter(-height));
  225. svg.append("text")
  226. .attr("x", width / 2)
  227. .attr("y", dimension)
  228. .style("text-anchor", "middle")
  229. .text(xLabel);
  230. return svg._groups[0][0].outerHTML;
  231. }
  232. /**
  233. * Hex Bin chart operation.
  234. *
  235. * @param {Object[]} - centres
  236. * @param {number} - radius
  237. * @returns {Object[]}
  238. */
  239. getEmptyHexagons(centres, radius) {
  240. const emptyCentres = [],
  241. boundingRect = [d3.extent(centres, d => d.x), d3.extent(centres, d => d.y)],
  242. hexagonCenterToEdge = Math.cos(2 * Math.PI / 12) * radius,
  243. hexagonEdgeLength = Math.sin(2 * Math.PI / 12) * radius;
  244. let indent = false;
  245. for (let y = boundingRect[1][0]; y <= boundingRect[1][1] + radius; y += hexagonEdgeLength + radius) {
  246. for (let x = boundingRect[0][0]; x <= boundingRect[0][1] + radius; x += 2 * hexagonCenterToEdge) {
  247. let cx = x;
  248. const cy = y;
  249. if (indent && x >= boundingRect[0][1]) break;
  250. if (indent) cx += hexagonCenterToEdge;
  251. emptyCentres.push({x: cx, y: cy});
  252. }
  253. indent = !indent;
  254. }
  255. return emptyCentres;
  256. }
  257. }
  258. export default HexDensityChart;