You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

379 lines
12 KiB

  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. .admin-pages-visualize-svg.pa-10(ref='svgContainer')
  33. 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!
  34. </template>
  35. <script>
  36. import _ from 'lodash'
  37. import * as d3 from 'd3'
  38. import gql from 'graphql-tag'
  39. /* global siteConfig, siteLangs */
  40. export default {
  41. data() {
  42. return {
  43. graphMode: 'htree',
  44. width: 800,
  45. radius: 400,
  46. pages: [],
  47. locales: siteLangs,
  48. currentLocale: siteConfig.lang
  49. }
  50. },
  51. watch: {
  52. pages () {
  53. this.redraw()
  54. },
  55. graphMode () {
  56. this.redraw()
  57. }
  58. },
  59. methods: {
  60. goToPage (d) {
  61. if (_.get(d, 'data.id', 0) > 0) {
  62. this.$router.push(`${d.data.id}`)
  63. }
  64. },
  65. bilink (root) {
  66. const map = new Map(root.descendants().map(d => [d.data.path, d]))
  67. for (const d of root.descendants()) {
  68. d.incoming = []
  69. d.outgoing = []
  70. d.data.links.forEach(i => {
  71. const relNode = map.get(i)
  72. if (relNode) {
  73. d.outgoing.push([d, relNode])
  74. }
  75. })
  76. }
  77. for (const d of root.descendants()) {
  78. for (const o of d.outgoing) {
  79. if (o[1]) {
  80. o[1].incoming.push(o)
  81. }
  82. }
  83. }
  84. return root
  85. },
  86. hierarchy (data, rootOnly = false) {
  87. let result = []
  88. let level = { result }
  89. const map = new Map(data.map(d => [d.path, d]))
  90. data.forEach(d => {
  91. const pathParts = d.path.split('/')
  92. pathParts.reduce((r, part, i) => {
  93. const curPath = _.take(pathParts, i + 1).join('/')
  94. if (!r[part]) {
  95. r[part] = { result: [] }
  96. const page = map.get(curPath)
  97. r.result.push(page ? {
  98. ...d,
  99. children: r[part].result
  100. } : {
  101. title: part,
  102. links: [],
  103. path: curPath,
  104. children: r[part].result
  105. })
  106. }
  107. return r[part]
  108. }, level)
  109. })
  110. return rootOnly ? _.head(result) || { children: [] } : {
  111. children: result
  112. }
  113. },
  114. /**
  115. * Relational Radial
  116. */
  117. drawRelations () {
  118. const data = this.hierarchy(this.pages, true)
  119. const line = d3.lineRadial()
  120. .curve(d3.curveBundle.beta(0.85))
  121. .radius(d => d.y)
  122. .angle(d => d.x)
  123. const tree = d3.cluster()
  124. .size([2 * Math.PI, this.radius - 100])
  125. const root = tree(this.bilink(d3.hierarchy(data)
  126. .sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.path, b.data.path))))
  127. const svg = d3.create('svg')
  128. .attr('viewBox', [-this.width / 2, -this.width / 2, this.width, this.width])
  129. const link = svg.append('g')
  130. .attr('stroke', '#CCC')
  131. .attr('fill', 'none')
  132. .selectAll('path')
  133. .data(root.descendants().flatMap(leaf => leaf.outgoing))
  134. .join('path')
  135. .style('mix-blend-mode', 'multiply')
  136. .attr('d', ([i, o]) => line(i.path(o)))
  137. .each(function(d) { d.path = this })
  138. svg.append('g')
  139. .attr('font-family', 'sans-serif')
  140. .attr('font-size', 10)
  141. .selectAll('g')
  142. .data(root.descendants())
  143. .join('g')
  144. .attr('transform', d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
  145. .append('text')
  146. .attr('dy', '0.31em')
  147. .attr('x', d => d.x < Math.PI ? 6 : -6)
  148. .attr('text-anchor', d => d.x < Math.PI ? 'start' : 'end')
  149. .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
  150. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  151. .attr('cursor', 'pointer')
  152. .text(d => d.data.title)
  153. .each(function(d) { d.text = this })
  154. .on('mouseover', overed)
  155. .on('mouseout', outed)
  156. .on('click', d => this.goToPage(d))
  157. .call(text => text.append('title').text(d => `${d.data.path}
  158. ${d.outgoing.length} outgoing
  159. ${d.incoming.length} incoming`))
  160. .clone(true).lower()
  161. .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
  162. function overed(d) {
  163. link.style('mix-blend-mode', null)
  164. d3.select(this).attr('font-weight', 'bold')
  165. d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', '#2196F3').raise()
  166. d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', '#2196F3').attr('font-weight', 'bold')
  167. d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', '#E91E63').raise()
  168. d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', '#E91E63').attr('font-weight', 'bold')
  169. }
  170. function outed(d) {
  171. link.style('mix-blend-mode', 'multiply')
  172. d3.select(this).attr('font-weight', null)
  173. d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', null)
  174. d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', null).attr('font-weight', null)
  175. d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', null)
  176. d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', null).attr('font-weight', null)
  177. }
  178. this.$refs.svgContainer.appendChild(svg.node())
  179. },
  180. /**
  181. * Hierarchical Tree
  182. */
  183. drawTree () {
  184. const data = this.hierarchy(this.pages, true)
  185. const treeRoot = d3.hierarchy(data)
  186. treeRoot.dx = 10
  187. treeRoot.dy = this.width / (treeRoot.height + 1)
  188. const root = d3.tree().nodeSize([treeRoot.dx, treeRoot.dy])(treeRoot)
  189. let x0 = Infinity
  190. let x1 = -x0
  191. root.each(d => {
  192. if (d.x > x1) x1 = d.x
  193. if (d.x < x0) x0 = d.x
  194. })
  195. const svg = d3.create('svg')
  196. .attr('viewBox', [0, 0, this.width, x1 - x0 + root.dx * 2])
  197. const g = svg.append('g')
  198. .attr('font-family', 'sans-serif')
  199. .attr('font-size', 10)
  200. .attr('transform', `translate(${root.dy / 3},${root.dx - x0})`)
  201. g.append('g')
  202. .attr('fill', 'none')
  203. .attr('stroke', this.$vuetify.theme.dark ? '#999' : '#555')
  204. .attr('stroke-opacity', 0.4)
  205. .attr('stroke-width', 1.5)
  206. .selectAll('path')
  207. .data(root.links())
  208. .join('path')
  209. .attr('d', d3.linkHorizontal()
  210. .x(d => d.y)
  211. .y(d => d.x))
  212. const node = g.append('g')
  213. .attr('stroke-linejoin', 'round')
  214. .attr('stroke-width', 3)
  215. .selectAll('g')
  216. .data(root.descendants())
  217. .join('g')
  218. .attr('transform', d => `translate(${d.y},${d.x})`)
  219. node.append('circle')
  220. .attr('fill', d => d.children ? '#555' : '#999')
  221. .attr('r', 2.5)
  222. node.append('text')
  223. .attr('dy', '0.31em')
  224. .attr('x', d => d.children ? -6 : 6)
  225. .attr('text-anchor', d => d.children ? 'end' : 'start')
  226. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  227. .attr('cursor', 'pointer')
  228. .text(d => d.data.title)
  229. .on('click', d => this.goToPage(d))
  230. .clone(true).lower()
  231. .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
  232. this.$refs.svgContainer.appendChild(svg.node())
  233. },
  234. /**
  235. * Hierarchical Radial
  236. */
  237. drawRadialTree () {
  238. const data = this.hierarchy(this.pages)
  239. const tree = d3.tree()
  240. .size([2 * Math.PI, this.radius])
  241. .separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth)
  242. const root = tree(d3.hierarchy(data)
  243. .sort((a, b) => d3.ascending(a.data.title, b.data.title)))
  244. const svg = d3.create('svg')
  245. .style('font', '10px sans-serif')
  246. svg.append('g')
  247. .attr('fill', 'none')
  248. .attr('stroke', this.$vuetify.theme.dark ? 'white' : '#555')
  249. .attr('stroke-opacity', 0.4)
  250. .attr('stroke-width', 1.5)
  251. .selectAll('path')
  252. .data(root.links())
  253. .join('path')
  254. .attr('d', d3.linkRadial()
  255. .angle(d => d.x)
  256. .radius(d => d.y))
  257. const node = svg.append('g')
  258. .attr('stroke-linejoin', 'round')
  259. .attr('stroke-width', 3)
  260. .selectAll('g')
  261. .data(root.descendants().reverse())
  262. .join('g')
  263. .attr('transform', d => `
  264. rotate(${d.x * 180 / Math.PI - 90})
  265. translate(${d.y},0)
  266. `)
  267. node.append('circle')
  268. .attr('fill', d => d.children ? '#555' : '#999')
  269. .attr('r', 2.5)
  270. node.append('text')
  271. .attr('dy', '0.31em')
  272. /* eslint-disable no-mixed-operators */
  273. .attr('x', d => d.x < Math.PI === !d.children ? 6 : -6)
  274. .attr('text-anchor', d => d.x < Math.PI === !d.children ? 'start' : 'end')
  275. .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
  276. /* eslint-enable no-mixed-operators */
  277. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  278. .attr('cursor', 'pointer')
  279. .text(d => d.data.title)
  280. .on('click', d => this.goToPage(d))
  281. .clone(true).lower()
  282. .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
  283. this.$refs.svgContainer.appendChild(svg.node())
  284. function autoBox() {
  285. const {x, y, width, height} = this.getBBox()
  286. return [x, y, width, height]
  287. }
  288. svg.attr('viewBox', autoBox)
  289. },
  290. redraw () {
  291. while (this.$refs.svgContainer.firstChild) {
  292. this.$refs.svgContainer.firstChild.remove()
  293. }
  294. if (this.pages.length > 0) {
  295. switch (this.graphMode) {
  296. case 'rradial':
  297. this.drawRelations()
  298. break
  299. case 'htree':
  300. this.drawTree()
  301. break
  302. case 'hradial':
  303. this.drawRadialTree()
  304. break
  305. }
  306. }
  307. }
  308. },
  309. apollo: {
  310. pages: {
  311. query: gql`
  312. query ($locale: String!) {
  313. pages {
  314. links(locale: $locale) {
  315. id
  316. path
  317. title
  318. links
  319. }
  320. }
  321. }
  322. `,
  323. variables () {
  324. return {
  325. locale: this.currentLocale
  326. }
  327. },
  328. fetchPolicy: 'network-only',
  329. update: (data) => data.pages.links,
  330. watchLoading (isLoading) {
  331. this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-pages-refresh')
  332. }
  333. }
  334. }
  335. }
  336. </script>
  337. <style lang='scss'>
  338. .admin-pages-visualize-svg {
  339. text-align: center;
  340. > svg {
  341. height: 100vh;
  342. }
  343. }
  344. </style>