Rose.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
  2. /**
  3. * Licensed to the Apache Software Foundation (ASF) under one
  4. * or more contributor license agreements. See the NOTICE file
  5. * distributed with this work for additional information
  6. * regarding copyright ownership. The ASF licenses this file
  7. * to you under the Apache License, Version 2.0 (the
  8. * "License"); you may not use this file except in compliance
  9. * with the License. You may obtain a copy of the License at
  10. *
  11. * http://www.apache.org/licenses/LICENSE-2.0
  12. *
  13. * Unless required by applicable law or agreed to in writing,
  14. * software distributed under the License is distributed on an
  15. * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  16. * KIND, either express or implied. See the License for the
  17. * specific language governing permissions and limitations
  18. * under the License.
  19. */
  20. /* eslint no-use-before-define: ["error", { "functions": false }] */
  21. /* eslint-disable no-restricted-syntax, no-plusplus */
  22. /* eslint-disable react/sort-prop-types */
  23. import d3 from 'd3';
  24. import PropTypes from 'prop-types';
  25. import nv from 'nvd3';
  26. import { CategoricalColorNamespace } from '@superset-ui/color';
  27. import { getNumberFormatter } from '@superset-ui/number-format';
  28. import { getTimeFormatter } from '@superset-ui/time-format';
  29. import './Rose.css';
  30. const propTypes = {
  31. // Data is an object hashed by numeric value, perhaps timestamp
  32. data: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.shape({
  33. key: PropTypes.arrayOf(PropTypes.string),
  34. name: PropTypes.arrayOf(PropTypes.string),
  35. time: PropTypes.number,
  36. value: PropTypes.number
  37. }))),
  38. width: PropTypes.number,
  39. height: PropTypes.number,
  40. dateTimeFormat: PropTypes.string,
  41. numberFormat: PropTypes.string,
  42. useRichTooltip: PropTypes.bool,
  43. useAreaProportions: PropTypes.bool
  44. };
  45. function copyArc(d) {
  46. return {
  47. startAngle: d.startAngle,
  48. endAngle: d.endAngle,
  49. innerRadius: d.innerRadius,
  50. outerRadius: d.outerRadius
  51. };
  52. }
  53. function sortValues(a, b) {
  54. if (a.value === b.value) {
  55. return a.name > b.name ? 1 : -1;
  56. }
  57. return b.value - a.value;
  58. }
  59. function Rose(element, props) {
  60. const {
  61. data,
  62. width,
  63. height,
  64. colorScheme,
  65. dateTimeFormat,
  66. numberFormat,
  67. useRichTooltip,
  68. useAreaProportions
  69. } = props;
  70. const div = d3.select(element);
  71. div.classed('superset-legacy-chart-rose', true);
  72. const datum = data;
  73. const times = Object.keys(datum).map(t => parseInt(t, 10)).sort((a, b) => a - b);
  74. const numGrains = times.length;
  75. const numGroups = datum[times[0]].length;
  76. const format = getNumberFormatter(numberFormat);
  77. const timeFormat = getTimeFormatter(dateTimeFormat);
  78. const colorFn = CategoricalColorNamespace.getScale(colorScheme);
  79. d3.select('.nvtooltip').remove();
  80. div.selectAll('*').remove();
  81. const arc = d3.svg.arc();
  82. const legend = nv.models.legend();
  83. const tooltip = nv.models.tooltip();
  84. const state = {
  85. disabled: datum[times[0]].map(() => false)
  86. };
  87. const svg = div.append('svg').attr('width', width).attr('height', height);
  88. const g = svg.append('g').attr('class', 'rose').append('g');
  89. const legendWrap = g.append('g').attr('class', 'legendWrap');
  90. function legendData(adatum) {
  91. return adatum[times[0]].map((v, i) => ({
  92. disabled: state.disabled[i],
  93. key: v.name
  94. }));
  95. }
  96. function tooltipData(d, i, adatum) {
  97. const timeIndex = Math.floor(d.arcId / numGroups);
  98. const series = useRichTooltip ? adatum[times[timeIndex]].filter(v => !state.disabled[v.id % numGroups]).map(v => ({
  99. key: v.name,
  100. value: v.value,
  101. color: colorFn(v.name),
  102. highlight: v.id === d.arcId
  103. })) : [{
  104. key: d.name,
  105. value: d.val,
  106. color: colorFn(d.name)
  107. }];
  108. return {
  109. key: 'Date',
  110. value: d.time,
  111. series
  112. };
  113. }
  114. legend.width(width).color(d => colorFn(d.key));
  115. legendWrap.datum(legendData(datum)).call(legend);
  116. tooltip.headerFormatter(timeFormat).valueFormatter(format); // Compute max radius, which the largest value will occupy
  117. const roseHeight = height - legend.height();
  118. const margin = {
  119. top: legend.height()
  120. };
  121. const edgeMargin = 35; // space between outermost radius and slice edge
  122. const maxRadius = Math.min(width, roseHeight) / 2 - edgeMargin;
  123. const labelThreshold = 0.05;
  124. const gro = 8; // mouseover radius growth in pixels
  125. const mini = 0.075;
  126. const centerTranslate = "translate(" + width / 2 + "," + (roseHeight / 2 + margin.top) + ")";
  127. const roseWrap = g.append('g').attr('transform', centerTranslate).attr('class', 'roseWrap');
  128. const labelsWrap = g.append('g').attr('transform', centerTranslate).attr('class', 'labelsWrap');
  129. const groupLabelsWrap = g.append('g').attr('transform', centerTranslate).attr('class', 'groupLabelsWrap'); // Compute inner and outer angles for each data point
  130. function computeArcStates(adatum) {
  131. // Find the max sum of values across all time
  132. let maxSum = 0;
  133. let grain = 0;
  134. const sums = [];
  135. for (const t of times) {
  136. const sum = datum[t].reduce((a, v, i) => a + (state.disabled[i] ? 0 : v.value), 0);
  137. maxSum = sum > maxSum ? sum : maxSum;
  138. sums[grain] = sum;
  139. grain++;
  140. } // Compute angle occupied by each time grain
  141. const dtheta = Math.PI * 2 / numGrains;
  142. const angles = [];
  143. for (let i = 0; i <= numGrains; i++) {
  144. angles.push(dtheta * i - Math.PI / 2);
  145. } // Compute proportion
  146. const P = maxRadius / maxSum;
  147. const Q = P * maxRadius;
  148. const computeOuterRadius = (value, innerRadius) => useAreaProportions ? Math.sqrt(Q * value + innerRadius * innerRadius) : P * value + innerRadius;
  149. const arcSt = {
  150. data: [],
  151. extend: {},
  152. push: {},
  153. pieStart: {},
  154. pie: {},
  155. pieOver: {},
  156. mini: {},
  157. labels: [],
  158. groupLabels: []
  159. };
  160. let arcId = 0;
  161. for (let i = 0; i < numGrains; i++) {
  162. const t = times[i];
  163. const startAngle = angles[i];
  164. const endAngle = angles[i + 1];
  165. const G = 2 * Math.PI / sums[i];
  166. let innerRadius = 0;
  167. let outerRadius;
  168. let pieStartAngle = 0;
  169. let pieEndAngle;
  170. for (const v of adatum[t]) {
  171. const val = state.disabled[arcId % numGroups] ? 0 : v.value;
  172. const {
  173. name,
  174. time
  175. } = v;
  176. v.id = arcId;
  177. outerRadius = computeOuterRadius(val, innerRadius);
  178. arcSt.data.push({
  179. startAngle,
  180. endAngle,
  181. innerRadius,
  182. outerRadius,
  183. name,
  184. arcId,
  185. val,
  186. time
  187. });
  188. arcSt.extend[arcId] = {
  189. startAngle,
  190. endAngle,
  191. innerRadius,
  192. name,
  193. outerRadius: outerRadius + gro
  194. };
  195. arcSt.push[arcId] = {
  196. startAngle,
  197. endAngle,
  198. innerRadius: innerRadius + gro,
  199. outerRadius: outerRadius + gro
  200. };
  201. arcSt.pieStart[arcId] = {
  202. startAngle,
  203. endAngle,
  204. innerRadius: mini * maxRadius,
  205. outerRadius: maxRadius
  206. };
  207. arcSt.mini[arcId] = {
  208. startAngle,
  209. endAngle,
  210. innerRadius: innerRadius * mini,
  211. outerRadius: outerRadius * mini
  212. };
  213. arcId++;
  214. innerRadius = outerRadius;
  215. }
  216. const labelArc = _extends({}, arcSt.data[i * numGroups]);
  217. labelArc.outerRadius = maxRadius + 20;
  218. labelArc.innerRadius = maxRadius + 15;
  219. arcSt.labels.push(labelArc);
  220. for (const v of adatum[t].concat().sort(sortValues)) {
  221. const val = state.disabled[v.id % numGroups] ? 0 : v.value;
  222. pieEndAngle = G * val + pieStartAngle;
  223. arcSt.pie[v.id] = {
  224. startAngle: pieStartAngle,
  225. endAngle: pieEndAngle,
  226. innerRadius: maxRadius * mini,
  227. outerRadius: maxRadius,
  228. percent: v.value / sums[i]
  229. };
  230. arcSt.pieOver[v.id] = {
  231. startAngle: pieStartAngle,
  232. endAngle: pieEndAngle,
  233. innerRadius: maxRadius * mini,
  234. outerRadius: maxRadius + gro
  235. };
  236. pieStartAngle = pieEndAngle;
  237. }
  238. }
  239. arcSt.groupLabels = arcSt.data.slice(0, numGroups);
  240. return arcSt;
  241. }
  242. let arcSt = computeArcStates(datum);
  243. function tween(target, resFunc) {
  244. return function doTween(d) {
  245. const interpolate = d3.interpolate(copyArc(d), copyArc(target));
  246. return t => resFunc(Object.assign(d, interpolate(t)));
  247. };
  248. }
  249. function arcTween(target) {
  250. return tween(target, d => arc(d));
  251. }
  252. function translateTween(target) {
  253. return tween(target, d => "translate(" + arc.centroid(d) + ")");
  254. } // Grab the ID range of segments stand between
  255. // this segment and the edge of the circle
  256. const segmentsToEdgeCache = {};
  257. function getSegmentsToEdge(arcId) {
  258. if (segmentsToEdgeCache[arcId]) {
  259. return segmentsToEdgeCache[arcId];
  260. }
  261. const timeIndex = Math.floor(arcId / numGroups);
  262. segmentsToEdgeCache[arcId] = [arcId + 1, numGroups * (timeIndex + 1) - 1];
  263. return segmentsToEdgeCache[arcId];
  264. } // Get the IDs of all segments in a timeIndex
  265. const segmentsInTimeCache = {};
  266. function getSegmentsInTime(arcId) {
  267. if (segmentsInTimeCache[arcId]) {
  268. return segmentsInTimeCache[arcId];
  269. }
  270. const timeIndex = Math.floor(arcId / numGroups);
  271. segmentsInTimeCache[arcId] = [timeIndex * numGroups, (timeIndex + 1) * numGroups - 1];
  272. return segmentsInTimeCache[arcId];
  273. }
  274. let clickId = -1;
  275. let inTransition = false;
  276. const ae = roseWrap.selectAll('g').data(JSON.parse(JSON.stringify(arcSt.data))) // deep copy data state
  277. .enter().append('g').attr('class', 'segment').classed('clickable', true).on('mouseover', mouseover).on('mouseout', mouseout).on('mousemove', mousemove).on('click', click);
  278. const labels = labelsWrap.selectAll('g').data(JSON.parse(JSON.stringify(arcSt.labels))).enter().append('g').attr('class', 'roseLabel').attr('transform', d => "translate(" + arc.centroid(d) + ")");
  279. labels.append('text').style('text-anchor', 'middle').style('fill', '#000').text(d => timeFormat(d.time));
  280. const groupLabels = groupLabelsWrap.selectAll('g').data(JSON.parse(JSON.stringify(arcSt.groupLabels))).enter().append('g');
  281. groupLabels.style('opacity', 0).attr('class', 'roseGroupLabels').append('text').style('text-anchor', 'middle').style('fill', '#000').text(d => d.name);
  282. const arcs = ae.append('path').attr('class', 'arc').attr('fill', d => colorFn(d.name)).attr('d', arc);
  283. function mousemove() {
  284. tooltip();
  285. }
  286. function mouseover(b, i) {
  287. tooltip.data(tooltipData(b, i, datum)).hidden(false);
  288. const $this = d3.select(this);
  289. $this.classed('hover', true);
  290. if (clickId < 0 && !inTransition) {
  291. $this.select('path').interrupt().transition().duration(180).attrTween('d', arcTween(arcSt.extend[i]));
  292. const edge = getSegmentsToEdge(i);
  293. arcs.filter(d => edge[0] <= d.arcId && d.arcId <= edge[1]).interrupt().transition().duration(180).attrTween('d', d => arcTween(arcSt.push[d.arcId])(d));
  294. } else if (!inTransition) {
  295. const segments = getSegmentsInTime(clickId);
  296. if (segments[0] <= b.arcId && b.arcId <= segments[1]) {
  297. $this.select('path').interrupt().transition().duration(180).attrTween('d', arcTween(arcSt.pieOver[i]));
  298. }
  299. }
  300. }
  301. function mouseout(b, i) {
  302. tooltip.hidden(true);
  303. const $this = d3.select(this);
  304. $this.classed('hover', false);
  305. if (clickId < 0 && !inTransition) {
  306. $this.select('path').interrupt().transition().duration(180).attrTween('d', arcTween(arcSt.data[i]));
  307. const edge = getSegmentsToEdge(i);
  308. arcs.filter(d => edge[0] <= d.arcId && d.arcId <= edge[1]).interrupt().transition().duration(180).attrTween('d', d => arcTween(arcSt.data[d.arcId])(d));
  309. } else if (!inTransition) {
  310. const segments = getSegmentsInTime(clickId);
  311. if (segments[0] <= b.arcId && b.arcId <= segments[1]) {
  312. $this.select('path').interrupt().transition().duration(180).attrTween('d', arcTween(arcSt.pie[i]));
  313. }
  314. }
  315. }
  316. function click(b, i) {
  317. if (inTransition) {
  318. return;
  319. }
  320. const delay = d3.event.altKey ? 3750 : 375;
  321. const segments = getSegmentsInTime(i);
  322. if (clickId < 0) {
  323. inTransition = true;
  324. clickId = i;
  325. labels.interrupt().transition().duration(delay).attrTween('transform', d => translateTween({
  326. outerRadius: 0,
  327. innerRadius: 0,
  328. startAngle: d.startAngle,
  329. endAngle: d.endAngle
  330. })(d)).style('opacity', 0);
  331. groupLabels.attr('transform', "translate(" + arc.centroid({
  332. outerRadius: maxRadius + 20,
  333. innerRadius: maxRadius + 15,
  334. startAngle: arcSt.data[i].startAngle,
  335. endAngle: arcSt.data[i].endAngle
  336. }) + ")").interrupt().transition().delay(delay).duration(delay).attrTween('transform', d => translateTween({
  337. outerRadius: maxRadius + 20,
  338. innerRadius: maxRadius + 15,
  339. startAngle: arcSt.pie[segments[0] + d.arcId].startAngle,
  340. endAngle: arcSt.pie[segments[0] + d.arcId].endAngle
  341. })(d)).style('opacity', d => state.disabled[d.arcId] || arcSt.pie[segments[0] + d.arcId].percent < labelThreshold ? 0 : 1);
  342. ae.classed('clickable', d => segments[0] > d.arcId || d.arcId > segments[1]);
  343. arcs.filter(d => segments[0] <= d.arcId && d.arcId <= segments[1]).interrupt().transition().duration(delay).attrTween('d', d => arcTween(arcSt.pieStart[d.arcId])(d)).transition().duration(delay).attrTween('d', d => arcTween(arcSt.pie[d.arcId])(d)).each('end', () => {
  344. inTransition = false;
  345. });
  346. arcs.filter(d => segments[0] > d.arcId || d.arcId > segments[1]).interrupt().transition().duration(delay).attrTween('d', d => arcTween(arcSt.mini[d.arcId])(d));
  347. } else if (clickId < segments[0] || segments[1] < clickId) {
  348. inTransition = true;
  349. const clickSegments = getSegmentsInTime(clickId);
  350. labels.interrupt().transition().delay(delay).duration(delay).attrTween('transform', d => translateTween(arcSt.labels[d.arcId / numGroups])(d)).style('opacity', 1);
  351. groupLabels.interrupt().transition().duration(delay).attrTween('transform', translateTween({
  352. outerRadius: maxRadius + 20,
  353. innerRadius: maxRadius + 15,
  354. startAngle: arcSt.data[clickId].startAngle,
  355. endAngle: arcSt.data[clickId].endAngle
  356. })).style('opacity', 0);
  357. ae.classed('clickable', true);
  358. arcs.filter(d => clickSegments[0] <= d.arcId && d.arcId <= clickSegments[1]).interrupt().transition().duration(delay).attrTween('d', d => arcTween(arcSt.pieStart[d.arcId])(d)).transition().duration(delay).attrTween('d', d => arcTween(arcSt.data[d.arcId])(d)).each('end', () => {
  359. clickId = -1;
  360. inTransition = false;
  361. });
  362. arcs.filter(d => clickSegments[0] > d.arcId || d.arcId > clickSegments[1]).interrupt().transition().delay(delay).duration(delay).attrTween('d', d => arcTween(arcSt.data[d.arcId])(d));
  363. }
  364. }
  365. function updateActive() {
  366. const delay = d3.event.altKey ? 3000 : 300;
  367. legendWrap.datum(legendData(datum)).call(legend);
  368. const nArcSt = computeArcStates(datum);
  369. inTransition = true;
  370. if (clickId < 0) {
  371. arcs.style('opacity', 1).interrupt().transition().duration(delay).attrTween('d', d => arcTween(nArcSt.data[d.arcId])(d)).each('end', () => {
  372. inTransition = false;
  373. arcSt = nArcSt;
  374. }).transition().duration(0).style('opacity', d => state.disabled[d.arcId % numGroups] ? 0 : 1);
  375. } else {
  376. const segments = getSegmentsInTime(clickId);
  377. arcs.style('opacity', 1).interrupt().transition().duration(delay).attrTween('d', d => segments[0] <= d.arcId && d.arcId <= segments[1] ? arcTween(nArcSt.pie[d.arcId])(d) : arcTween(nArcSt.mini[d.arcId])(d)).each('end', () => {
  378. inTransition = false;
  379. arcSt = nArcSt;
  380. }).transition().duration(0).style('opacity', d => state.disabled[d.arcId % numGroups] ? 0 : 1);
  381. groupLabels.interrupt().transition().duration(delay).attrTween('transform', d => translateTween({
  382. outerRadius: maxRadius + 20,
  383. innerRadius: maxRadius + 15,
  384. startAngle: nArcSt.pie[segments[0] + d.arcId].startAngle,
  385. endAngle: nArcSt.pie[segments[0] + d.arcId].endAngle
  386. })(d)).style('opacity', d => state.disabled[d.arcId] || arcSt.pie[segments[0] + d.arcId].percent < labelThreshold ? 0 : 1);
  387. }
  388. }
  389. legend.dispatch.on('stateChange', newState => {
  390. if (state.disabled !== newState.disabled) {
  391. state.disabled = newState.disabled;
  392. updateActive();
  393. }
  394. });
  395. }
  396. Rose.displayName = 'Rose';
  397. Rose.propTypes = propTypes;
  398. export default Rose;