Treemap.jsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import {Component, CSSProperties} from 'react';
  2. import FoamTree from '@carrotsearch/foamtree';
  3. export default class Treemap extends Component {
  4. constructor(props) {
  5. super(props);
  6. this.treemap = null;
  7. this.zoomOutDisabled = false;
  8. this.findChunkNamePartIndex();
  9. }
  10. componentDidMount() {
  11. this.treemap = this.createTreemap();
  12. window.addEventListener('resize', this.resize);
  13. }
  14. componentWillReceiveProps(nextProps) {
  15. if (nextProps.data !== this.props.data) {
  16. this.findChunkNamePartIndex();
  17. this.treemap.set({
  18. dataObject: this.getTreemapDataObject(nextProps.data),
  19. });
  20. } else if (nextProps.highlightGroups !== this.props.highlightGroups) {
  21. setTimeout(() => this.treemap.redraw());
  22. }
  23. }
  24. shouldComponentUpdate() {
  25. return false;
  26. }
  27. componentWillUnmount() {
  28. window.removeEventListener('resize', this.resize);
  29. this.treemap.dispose();
  30. }
  31. saveNodeRef = node => (this.node = node);
  32. getTreemapDataObject(data = this.props.data) {
  33. return {groups: data};
  34. }
  35. createTreemap() {
  36. const component = this;
  37. const {props} = this;
  38. return new FoamTree({
  39. element: this.node,
  40. layout: 'squarified',
  41. stacking: 'flattened',
  42. pixelRatio: window.devicePixelRatio || 1,
  43. maxGroups: Infinity,
  44. maxGroupLevelsDrawn: Infinity,
  45. maxGroupLabelLevelsDrawn: Infinity,
  46. maxGroupLevelsAttached: Infinity,
  47. wireframeLabelDrawing: 'always',
  48. groupMinDiameter: 0,
  49. groupLabelVerticalPadding: 0.2,
  50. rolloutDuration: 0,
  51. pullbackDuration: 0,
  52. fadeDuration: 0,
  53. groupExposureZoomMargin: 0.2,
  54. zoomMouseWheelDuration: 300,
  55. openCloseDuration: 200,
  56. dataObject: this.getTreemapDataObject(),
  57. titleBarDecorator(opts, props, vars) {
  58. vars.titleBarShown = false;
  59. },
  60. groupColorDecorator(options, properties, variables) {
  61. const root = component.getGroupRoot(properties.group);
  62. const chunkName = component.getChunkNamePart(root.label);
  63. const hash = /[^0-9]/u.test(chunkName)
  64. ? hashCode(chunkName)
  65. : (parseInt(chunkName) / 1000) * 360;
  66. variables.groupColor = {
  67. model: 'hsla',
  68. h: Math.round(Math.abs(hash) % 360),
  69. s: 60,
  70. l: 50,
  71. a: 0.9,
  72. };
  73. const {highlightGroups} = component.props;
  74. const module = properties.group;
  75. if (highlightGroups && highlightGroups.has(module)) {
  76. variables.groupColor = {
  77. model: 'rgba',
  78. r: 255,
  79. g: 0,
  80. b: 0,
  81. a: 0.8,
  82. };
  83. } else if (highlightGroups && highlightGroups.size > 0) {
  84. // this means a search (e.g.) is active, but this module
  85. // does not match; gray it out
  86. // https://github.com/webpack-contrib/webpack-bundle-analyzer/issues/553
  87. variables.groupColor.s = 10;
  88. }
  89. },
  90. /**
  91. * Handle Foamtree's "group clicked" event
  92. * @param {FoamtreeEvent} event - Foamtree event object
  93. * (see https://get.carrotsearch.com/foamtree/demo/api/index.html#event-details)
  94. * @returns {void}
  95. */
  96. onGroupClick(event) {
  97. preventDefault(event);
  98. if ((event.ctrlKey || event.secondary) && props.onGroupSecondaryClick) {
  99. props.onGroupSecondaryClick.call(component, event);
  100. return;
  101. }
  102. component.zoomOutDisabled = false;
  103. this.zoom(event.group);
  104. },
  105. onGroupDoubleClick: preventDefault,
  106. onGroupHover(event) {
  107. // Ignoring hovering on `FoamTree` branding group and the root group
  108. if (
  109. event.group &&
  110. (event.group.attribution || event.group === this.get('dataObject'))
  111. ) {
  112. event.preventDefault();
  113. if (props.onMouseLeave) {
  114. props.onMouseLeave.call(component, event);
  115. }
  116. return;
  117. }
  118. if (props.onGroupHover) {
  119. props.onGroupHover.call(component, event);
  120. }
  121. },
  122. onGroupMouseWheel(event) {
  123. const {scale} = this.get('viewport');
  124. const isZoomOut = event.delta < 0;
  125. if (isZoomOut) {
  126. if (component.zoomOutDisabled) {
  127. return preventDefault(event);
  128. }
  129. if (scale < 1) {
  130. component.zoomOutDisabled = true;
  131. preventDefault(event);
  132. }
  133. } else {
  134. component.zoomOutDisabled = false;
  135. }
  136. },
  137. });
  138. }
  139. getGroupRoot(group) {
  140. let nextParent;
  141. while (!group.isAsset && (nextParent = this.treemap.get('hierarchy', group).parent)) {
  142. group = nextParent;
  143. }
  144. return group;
  145. }
  146. zoomToGroup(group) {
  147. this.zoomOutDisabled = false;
  148. while (group && !this.treemap.get('state', group).revealed) {
  149. group = this.treemap.get('hierarchy', group).parent;
  150. }
  151. if (group) {
  152. this.treemap.zoom(group);
  153. }
  154. }
  155. isGroupRendered(group) {
  156. const groupState = this.treemap.get('state', group);
  157. return !!groupState && groupState.revealed;
  158. }
  159. update() {
  160. this.treemap.update();
  161. }
  162. resize = () => {
  163. const {props} = this;
  164. this.treemap.resize();
  165. if (props.onResize) {
  166. props.onResize();
  167. }
  168. };
  169. /**
  170. * Finds patterns across all chunk names to identify the unique "name" part.
  171. */
  172. findChunkNamePartIndex() {
  173. const splitChunkNames = this.props.data.map(chunk =>
  174. chunk.label.split(/[^a-z0-9]/iu)
  175. );
  176. const longestSplitName = Math.max(...splitChunkNames.map(parts => parts.length));
  177. const namePart = {
  178. index: 0,
  179. votes: 0,
  180. };
  181. for (let i = longestSplitName - 1; i >= 0; i--) {
  182. const identifierVotes = {
  183. name: 0,
  184. hash: 0,
  185. ext: 0,
  186. };
  187. let lastChunkPart = '';
  188. for (const splitChunkName of splitChunkNames) {
  189. const part = splitChunkName[i];
  190. if (part === undefined || part === '') {
  191. continue;
  192. }
  193. if (part === lastChunkPart) {
  194. identifierVotes.ext++;
  195. } else if (
  196. /[a-z]/u.test(part) &&
  197. /[0-9]/u.test(part) &&
  198. part.length === lastChunkPart.length
  199. ) {
  200. identifierVotes.hash++;
  201. } else if (/^[a-z]+$/iu.test(part) || /^[0-9]+$/u.test(part)) {
  202. identifierVotes.name++;
  203. }
  204. lastChunkPart = part;
  205. }
  206. if (identifierVotes.name >= namePart.votes) {
  207. namePart.index = i;
  208. namePart.votes = identifierVotes.name;
  209. }
  210. }
  211. this.chunkNamePartIndex = namePart.index;
  212. }
  213. getChunkNamePart(chunkLabel) {
  214. return chunkLabel.split(/[^a-z0-9]/iu)[this.chunkNamePartIndex] || chunkLabel;
  215. }
  216. render() {
  217. return (
  218. <div
  219. style={{width: '100%', height: 'calc(100vh - 300px)'}}
  220. {...this.props}
  221. ref={this.saveNodeRef}
  222. />
  223. );
  224. }
  225. }
  226. function preventDefault(event) {
  227. event.preventDefault();
  228. }
  229. function hashCode(str) {
  230. let hash = 0;
  231. for (let i = 0; i < str.length; i++) {
  232. const code = str.charCodeAt(i);
  233. hash = (hash << 5) - hash + code;
  234. hash = hash & hash;
  235. }
  236. return hash;
  237. }