admin-pages-visualize.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. <template lang='pug'>
  2. v-container(fluid, grid-list-lg)
  3. v-layout(row wrap)
  4. v-flex(xs12)
  5. .admin-header
  6. img.animated.fadeInUp(src='/svg/icon-venn-diagram.svg', alt='Visualize Pages', style='width: 80px;')
  7. .admin-header-title
  8. .headline.blue--text.text--darken-2.animated.fadeInLeft Visualize Pages
  9. .subtitle-1.grey--text.animated.fadeInLeft.wait-p2s Dendrogram representation of your pages
  10. v-spacer
  11. v-select.mx-5.animated.fadeInDown.wait-p1s(
  12. v-if='locales.length > 0'
  13. v-model='currentLocale'
  14. :items='locales'
  15. style='flex: 0 1 120px;'
  16. solo
  17. dense
  18. hide-details
  19. item-value='code'
  20. item-text='name'
  21. )
  22. v-btn-toggle.animated.fadeInDown(v-model='graphMode', color='primary', dense, rounded)
  23. v-btn.px-5(value='htree')
  24. v-icon(left, :color='graphMode === `htree` ? `primary` : `grey darken-3`') mdi-sitemap
  25. span.text-none Hierarchical Tree
  26. v-btn.px-5(value='hradial')
  27. v-icon(left, :color='graphMode === `hradial` ? `primary` : `grey darken-3`') mdi-chart-donut-variant
  28. span.text-none Hierarchical Radial
  29. v-btn.px-5(value='rradial')
  30. v-icon(left, :color='graphMode === `rradial` ? `primary` : `grey darken-3`') mdi-blur-radial
  31. span.text-none Relational Radial
  32. v-chip.ml-3(x-small) Beta
  33. .admin-pages-visualize-svg.pa-10(ref='svgContainer')
  34. v-alert(v-if='pages.length < 1', outlined, type='warning', style='max-width: 650px; margin: 0 auto;') Looks like there's no data yet to graph!
  35. </template>
  36. <script>
  37. import _ from 'lodash'
  38. import * as d3 from 'd3'
  39. import gql from 'graphql-tag'
  40. /* global siteConfig, siteLangs */
  41. export default {
  42. data() {
  43. return {
  44. graphMode: 'htree',
  45. width: 800,
  46. radius: 400,
  47. pages: [],
  48. locales: siteLangs,
  49. currentLocale: siteConfig.lang
  50. }
  51. },
  52. watch: {
  53. pages () {
  54. this.redraw()
  55. },
  56. graphMode () {
  57. this.redraw()
  58. }
  59. },
  60. methods: {
  61. bilink (root) {
  62. const map = new Map(root.leaves().map(d => [d.data.path, d]))
  63. for (const d of root.leaves()) {
  64. d.incoming = []
  65. d.outgoing = []
  66. d.data.links.forEach(i => {
  67. const relNode = map.get(i)
  68. if (relNode) {
  69. d.outgoing.push([d, relNode])
  70. }
  71. })
  72. }
  73. for (const d of root.leaves()) {
  74. for (const o of d.outgoing) {
  75. if (o[1]) {
  76. o[1].incoming.push(o)
  77. }
  78. }
  79. }
  80. return root
  81. },
  82. hierarchy (data, rootOnly = false) {
  83. let result = []
  84. let level = { result }
  85. const map = new Map(data.map(d => [d.path, d]))
  86. data.forEach(d => {
  87. const pathParts = d.path.split('/')
  88. pathParts.reduce((r, part, i) => {
  89. const curPath = _.take(pathParts, i + 1).join('/')
  90. if (!r[part]) {
  91. r[part] = { result: [] }
  92. const page = map.get(curPath)
  93. r.result.push(page ? {
  94. ...d,
  95. children: r[part].result
  96. } : {
  97. title: part,
  98. links: [],
  99. path: curPath,
  100. children: r[part].result
  101. })
  102. }
  103. return r[part]
  104. }, level)
  105. })
  106. return rootOnly ? _.head(result) || { children: [] } : {
  107. children: result
  108. }
  109. },
  110. drawRelations () {
  111. const data = this.hierarchy(this.pages)
  112. const line = d3.lineRadial()
  113. .curve(d3.curveBundle.beta(0.85))
  114. .radius(d => d.y)
  115. .angle(d => d.x)
  116. const tree = d3.cluster()
  117. .size([2 * Math.PI, this.radius - 100])
  118. const root = tree(this.bilink(d3.hierarchy(data)
  119. .sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.title, b.data.title))))
  120. const svg = d3.create('svg')
  121. .attr('viewBox', [-this.width / 2, -this.width / 2, this.width, this.width])
  122. svg.append('g')
  123. .attr('font-family', 'sans-serif')
  124. .attr('font-size', 10)
  125. .selectAll('g')
  126. .data(root.leaves())
  127. .join('g')
  128. .attr('transform', d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
  129. .append('text')
  130. .attr('dy', '0.31em')
  131. .attr('x', d => d.x < Math.PI ? 6 : -6)
  132. .attr('text-anchor', d => d.x < Math.PI ? 'start' : 'end')
  133. .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
  134. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  135. .text(d => d.data.title)
  136. .each(function(d) { d.text = this })
  137. .on('mouseover', overed)
  138. .on('mouseout', outed)
  139. .call(text => text.append('title').text(d => `${d.data.path}
  140. ${d.outgoing.length} outgoing
  141. ${d.incoming.length} incoming`))
  142. const link = svg.append('g')
  143. .attr('stroke', '#CCC')
  144. .attr('fill', 'none')
  145. .selectAll('path')
  146. .data(root.leaves().flatMap(leaf => leaf.outgoing))
  147. .join('path')
  148. .style('mix-blend-mode', 'multiply')
  149. .attr('d', ([i, o]) => line(i.path(o)))
  150. .each(function(d) { d.path = this })
  151. function overed(d) {
  152. link.style('mix-blend-mode', null)
  153. d3.select(this).attr('font-weight', 'bold')
  154. d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', '#2196F3').raise()
  155. d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', '#2196F3').attr('font-weight', 'bold')
  156. d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', '#E91E63').raise()
  157. d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', '#E91E63').attr('font-weight', 'bold')
  158. }
  159. function outed(d) {
  160. link.style('mix-blend-mode', 'multiply')
  161. d3.select(this).attr('font-weight', null)
  162. d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', null)
  163. d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', null).attr('font-weight', null)
  164. d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', null)
  165. d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', null).attr('font-weight', null)
  166. }
  167. this.$refs.svgContainer.appendChild(svg.node())
  168. },
  169. drawTree () {
  170. const data = this.hierarchy(this.pages, true)
  171. const treeRoot = d3.hierarchy(data)
  172. treeRoot.dx = 10
  173. treeRoot.dy = this.width / (treeRoot.height + 1)
  174. const root = d3.tree().nodeSize([treeRoot.dx, treeRoot.dy])(treeRoot)
  175. let x0 = Infinity
  176. let x1 = -x0
  177. root.each(d => {
  178. if (d.x > x1) x1 = d.x
  179. if (d.x < x0) x0 = d.x
  180. })
  181. const svg = d3.create('svg')
  182. .attr('viewBox', [0, 0, this.width, x1 - x0 + root.dx * 2])
  183. const g = svg.append('g')
  184. .attr('font-family', 'sans-serif')
  185. .attr('font-size', 10)
  186. .attr('transform', `translate(${root.dy / 3},${root.dx - x0})`)
  187. g.append('g')
  188. .attr('fill', 'none')
  189. .attr('stroke', this.$vuetify.theme.dark ? '#999' : '#555')
  190. .attr('stroke-opacity', 0.4)
  191. .attr('stroke-width', 1.5)
  192. .selectAll('path')
  193. .data(root.links())
  194. .join('path')
  195. .attr('d', d3.linkHorizontal()
  196. .x(d => d.y)
  197. .y(d => d.x))
  198. const node = g.append('g')
  199. .attr('stroke-linejoin', 'round')
  200. .attr('stroke-width', 3)
  201. .selectAll('g')
  202. .data(root.descendants())
  203. .join('g')
  204. .attr('transform', d => `translate(${d.y},${d.x})`)
  205. node.append('circle')
  206. .attr('fill', d => d.children ? '#555' : '#999')
  207. .attr('r', 2.5)
  208. node.append('text')
  209. .attr('dy', '0.31em')
  210. .attr('x', d => d.children ? -6 : 6)
  211. .attr('text-anchor', d => d.children ? 'end' : 'start')
  212. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  213. .text(d => d.data.title)
  214. .clone(true).lower()
  215. .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
  216. this.$refs.svgContainer.appendChild(svg.node())
  217. },
  218. drawRadialTree () {
  219. const data = this.hierarchy(this.pages)
  220. const tree = d3.tree()
  221. .size([2 * Math.PI, this.radius])
  222. .separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth)
  223. const root = tree(d3.hierarchy(data)
  224. .sort((a, b) => d3.ascending(a.data.title, b.data.title)))
  225. const svg = d3.create('svg')
  226. .style('font', '10px sans-serif')
  227. svg.append('g')
  228. .attr('fill', 'none')
  229. .attr('stroke', this.$vuetify.theme.dark ? 'white' : '#555')
  230. .attr('stroke-opacity', 0.4)
  231. .attr('stroke-width', 1.5)
  232. .selectAll('path')
  233. .data(root.links())
  234. .join('path')
  235. .attr('d', d3.linkRadial()
  236. .angle(d => d.x)
  237. .radius(d => d.y))
  238. const node = svg.append('g')
  239. .attr('stroke-linejoin', 'round')
  240. .attr('stroke-width', 3)
  241. .selectAll('g')
  242. .data(root.descendants().reverse())
  243. .join('g')
  244. .attr('transform', d => `
  245. rotate(${d.x * 180 / Math.PI - 90})
  246. translate(${d.y},0)
  247. `)
  248. node.append('circle')
  249. .attr('fill', d => d.children ? '#555' : '#999')
  250. .attr('r', 2.5)
  251. node.append('text')
  252. .attr('dy', '0.31em')
  253. /* eslint-disable no-mixed-operators */
  254. .attr('x', d => d.x < Math.PI === !d.children ? 6 : -6)
  255. .attr('text-anchor', d => d.x < Math.PI === !d.children ? 'start' : 'end')
  256. .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
  257. /* eslint-enable no-mixed-operators */
  258. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  259. .text(d => d.data.title)
  260. .clone(true).lower()
  261. .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
  262. this.$refs.svgContainer.appendChild(svg.node())
  263. function autoBox() {
  264. const {x, y, width, height} = this.getBBox()
  265. return [x, y, width, height]
  266. }
  267. svg.attr('viewBox', autoBox)
  268. },
  269. redraw () {
  270. while (this.$refs.svgContainer.firstChild) {
  271. this.$refs.svgContainer.firstChild.remove()
  272. }
  273. if (this.pages.length > 0) {
  274. switch (this.graphMode) {
  275. case 'rradial':
  276. this.drawRelations()
  277. break
  278. case 'htree':
  279. this.drawTree()
  280. break
  281. case 'hradial':
  282. this.drawRadialTree()
  283. break
  284. }
  285. }
  286. }
  287. },
  288. apollo: {
  289. pages: {
  290. query: gql`
  291. query ($locale: String!) {
  292. pages {
  293. links(locale: $locale) {
  294. id
  295. path
  296. title
  297. links
  298. }
  299. }
  300. }
  301. `,
  302. variables () {
  303. return {
  304. locale: this.currentLocale
  305. }
  306. },
  307. fetchPolicy: 'network-only',
  308. update: (data) => data.pages.links,
  309. watchLoading (isLoading) {
  310. this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-pages-refresh')
  311. }
  312. }
  313. }
  314. }
  315. </script>
  316. <style lang='scss'>
  317. .admin-pages-visualize-svg {
  318. text-align: center;
  319. > svg {
  320. height: 100vh;
  321. }
  322. }
  323. </style>