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.

405 lines
13 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='/_assets/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(ref='svgContainer', v-show='pages.length >= 1')
  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. const id = d.data.id
  62. if (id) {
  63. if (d3.event.ctrlKey || d3.event.metaKey) {
  64. const { href } = this.$router.resolve(String(id))
  65. window.open(href, '_blank')
  66. } else {
  67. this.$router.push(String(id))
  68. }
  69. }
  70. },
  71. bilink (root) {
  72. const map = new Map(root.descendants().map(d => [d.data.path, d]))
  73. for (const d of root.descendants()) {
  74. d.incoming = []
  75. d.outgoing = []
  76. d.data.links.forEach(i => {
  77. const relNode = map.get(i)
  78. if (relNode) {
  79. d.outgoing.push([d, relNode])
  80. }
  81. })
  82. }
  83. for (const d of root.descendants()) {
  84. for (const o of d.outgoing) {
  85. if (o[1]) {
  86. o[1].incoming.push(o)
  87. }
  88. }
  89. }
  90. return root
  91. },
  92. hierarchy (pages) {
  93. const map = new Map(pages.map(p => [p.path, p]))
  94. const getPage = path => map.get(path) || {
  95. path: path,
  96. title: path.split('/').slice(-1)[0],
  97. links: []
  98. }
  99. function recurse (depth, [parent, descendants]) {
  100. const truncatePath = path => _.take(path.split('/'), depth).join('/')
  101. const descendantsByChild =
  102. Object.entries(_.groupBy(descendants, page => truncatePath(page.path)))
  103. .map(([childPath, descendantsGroup]) => [getPage(childPath), descendantsGroup])
  104. .map(([child, descendantsGroup]) =>
  105. [child, _.filter(descendantsGroup, d => d.path !== child.path)])
  106. return {
  107. ...parent,
  108. children: descendantsByChild.map(_.partial(recurse, depth + 1))
  109. }
  110. }
  111. const root = { path: this.currentLocale, title: this.currentLocale, links: [] }
  112. // start at depth=2 because we're taking {locale} as the root and
  113. // all paths start with {locale}/
  114. return recurse(2, [root, pages])
  115. },
  116. /**
  117. * Relational Radial
  118. */
  119. drawRelations () {
  120. const data = this.hierarchy(this.pages)
  121. const line = d3.lineRadial()
  122. .curve(d3.curveBundle.beta(0.85))
  123. .radius(d => d.y)
  124. .angle(d => d.x)
  125. const tree = d3.cluster()
  126. .size([2 * Math.PI, this.radius - 100])
  127. const root = tree(this.bilink(d3.hierarchy(data)
  128. .sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.path, b.data.path))))
  129. const svg = d3.create('svg')
  130. .attr('viewBox', [-this.width / 2, -this.width / 2, this.width, this.width])
  131. const g = svg.append('g')
  132. svg.call(d3.zoom().on('zoom', function() {
  133. g.attr('transform', d3.event.transform)
  134. }))
  135. const link = g.append('g')
  136. .attr('stroke', '#CCC')
  137. .attr('fill', 'none')
  138. .selectAll('path')
  139. .data(root.descendants().flatMap(leaf => leaf.outgoing))
  140. .join('path')
  141. .style('mix-blend-mode', 'multiply')
  142. .attr('d', ([i, o]) => line(i.path(o)))
  143. .each(function(d) { d.path = this })
  144. g.append('g')
  145. .attr('font-family', 'sans-serif')
  146. .attr('font-size', 10)
  147. .selectAll('g')
  148. .data(root.descendants())
  149. .join('g')
  150. .attr('transform', d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
  151. .append('text')
  152. .attr('dy', '0.31em')
  153. .attr('x', d => d.x < Math.PI ? 6 : -6)
  154. .attr('text-anchor', d => d.x < Math.PI ? 'start' : 'end')
  155. .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
  156. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  157. .attr('cursor', 'pointer')
  158. .text(d => d.data.title)
  159. .each(function(d) { d.text = this })
  160. .on('mouseover', overed)
  161. .on('mouseout', outed)
  162. .on('click', d => this.goToPage(d))
  163. .call(text => text.append('title').text(d => `${d.data.path}
  164. ${d.outgoing.length} outgoing
  165. ${d.incoming.length} incoming`))
  166. .clone(true).lower()
  167. .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
  168. function overed(d) {
  169. link.style('mix-blend-mode', null)
  170. d3.select(this).attr('font-weight', 'bold')
  171. d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', '#2196F3').raise()
  172. d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', '#2196F3').attr('font-weight', 'bold')
  173. d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', '#E91E63').raise()
  174. d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', '#E91E63').attr('font-weight', 'bold')
  175. }
  176. function outed(d) {
  177. link.style('mix-blend-mode', 'multiply')
  178. d3.select(this).attr('font-weight', null)
  179. d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', null)
  180. d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', null).attr('font-weight', null)
  181. d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', null)
  182. d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', null).attr('font-weight', null)
  183. }
  184. this.$refs.svgContainer.appendChild(svg.node())
  185. },
  186. /**
  187. * Hierarchical Tree
  188. */
  189. drawTree () {
  190. const data = this.hierarchy(this.pages)
  191. const treeRoot = d3.hierarchy(data)
  192. treeRoot.dx = 10
  193. treeRoot.dy = this.width / (treeRoot.height + 1)
  194. const root = d3.tree().nodeSize([treeRoot.dx, treeRoot.dy])(treeRoot)
  195. let x0 = Infinity
  196. let x1 = -x0
  197. root.each(d => {
  198. if (d.x > x1) x1 = d.x
  199. if (d.x < x0) x0 = d.x
  200. })
  201. const svg = d3.create('svg')
  202. .attr('viewBox', [0, 0, this.width, x1 - x0 + root.dx * 2])
  203. // this extra level is necessary because the element that we
  204. // apply the zoom tranform to must be above the element where
  205. // we apply the translation (`g`), or else zoom is wonky
  206. const gZoom = svg.append('g')
  207. svg.call(d3.zoom().on('zoom', function() {
  208. gZoom.attr('transform', d3.event.transform)
  209. }))
  210. const g = gZoom.append('g')
  211. .attr('font-family', 'sans-serif')
  212. .attr('font-size', 10)
  213. .attr('transform', `translate(${root.dy / 3},${root.dx - x0})`)
  214. g.append('g')
  215. .attr('fill', 'none')
  216. .attr('stroke', this.$vuetify.theme.dark ? '#999' : '#555')
  217. .attr('stroke-opacity', 0.4)
  218. .attr('stroke-width', 1.5)
  219. .selectAll('path')
  220. .data(root.links())
  221. .join('path')
  222. .attr('d', d3.linkHorizontal()
  223. .x(d => d.y)
  224. .y(d => d.x))
  225. const node = g.append('g')
  226. .attr('stroke-linejoin', 'round')
  227. .attr('stroke-width', 3)
  228. .selectAll('g')
  229. .data(root.descendants())
  230. .join('g')
  231. .attr('transform', d => `translate(${d.y},${d.x})`)
  232. node.append('circle')
  233. .attr('fill', d => d.children ? '#555' : '#999')
  234. .attr('r', 2.5)
  235. node.append('text')
  236. .attr('dy', '0.31em')
  237. .attr('x', d => d.children ? -6 : 6)
  238. .attr('text-anchor', d => d.children ? 'end' : 'start')
  239. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  240. .attr('cursor', 'pointer')
  241. .text(d => d.data.title)
  242. .on('click', d => this.goToPage(d))
  243. .clone(true).lower()
  244. .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
  245. this.$refs.svgContainer.appendChild(svg.node())
  246. },
  247. /**
  248. * Hierarchical Radial
  249. */
  250. drawRadialTree () {
  251. const data = this.hierarchy(this.pages)
  252. const tree = d3.tree()
  253. .size([2 * Math.PI, this.radius])
  254. .separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth)
  255. const root = tree(d3.hierarchy(data)
  256. .sort((a, b) => d3.ascending(a.data.title, b.data.title)))
  257. const svg = d3.create('svg')
  258. .style('font', '10px sans-serif')
  259. const g = svg.append('g')
  260. svg.call(d3.zoom().on('zoom', function () {
  261. g.attr('transform', d3.event.transform)
  262. }))
  263. // eslint-disable-next-line no-unused-vars
  264. const link = g.append('g')
  265. .attr('fill', 'none')
  266. .attr('stroke', this.$vuetify.theme.dark ? 'white' : '#555')
  267. .attr('stroke-opacity', 0.4)
  268. .attr('stroke-width', 1.5)
  269. .selectAll('path')
  270. .data(root.links())
  271. .join('path')
  272. .attr('d', d3.linkRadial()
  273. .angle(d => d.x)
  274. .radius(d => d.y))
  275. const node = g.append('g')
  276. .attr('stroke-linejoin', 'round')
  277. .attr('stroke-width', 3)
  278. .selectAll('g')
  279. .data(root.descendants().reverse())
  280. .join('g')
  281. .attr('transform', d => `
  282. rotate(${d.x * 180 / Math.PI - 90})
  283. translate(${d.y},0)
  284. `)
  285. node.append('circle')
  286. .attr('fill', d => d.children ? '#555' : '#999')
  287. .attr('r', 2.5)
  288. node.append('text')
  289. .attr('dy', '0.31em')
  290. /* eslint-disable no-mixed-operators */
  291. .attr('x', d => d.x < Math.PI === !d.children ? 6 : -6)
  292. .attr('text-anchor', d => d.x < Math.PI === !d.children ? 'start' : 'end')
  293. .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
  294. /* eslint-enable no-mixed-operators */
  295. .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
  296. .attr('cursor', 'pointer')
  297. .text(d => d.data.title)
  298. .on('click', d => this.goToPage(d))
  299. .clone(true).lower()
  300. .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
  301. this.$refs.svgContainer.appendChild(svg.node())
  302. function autoBox() {
  303. const {x, y, width, height} = this.getBBox()
  304. return [x, y, width, height]
  305. }
  306. svg.attr('viewBox', autoBox)
  307. },
  308. redraw () {
  309. while (this.$refs.svgContainer.firstChild) {
  310. this.$refs.svgContainer.firstChild.remove()
  311. }
  312. if (this.pages.length > 0) {
  313. switch (this.graphMode) {
  314. case 'rradial':
  315. this.drawRelations()
  316. break
  317. case 'htree':
  318. this.drawTree()
  319. break
  320. case 'hradial':
  321. this.drawRadialTree()
  322. break
  323. }
  324. }
  325. }
  326. },
  327. apollo: {
  328. pages: {
  329. query: gql`
  330. query ($locale: String!) {
  331. pages {
  332. links(locale: $locale) {
  333. id
  334. path
  335. title
  336. links
  337. }
  338. }
  339. }
  340. `,
  341. variables () {
  342. return {
  343. locale: this.currentLocale
  344. }
  345. },
  346. fetchPolicy: 'network-only',
  347. update: (data) => data.pages.links,
  348. watchLoading (isLoading) {
  349. this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-pages-refresh')
  350. }
  351. }
  352. }
  353. }
  354. </script>
  355. <style lang='scss'>
  356. .admin-pages-visualize-svg {
  357. text-align: center;
  358. // 100vh - header - title section - footer - content padding
  359. height: calc(100vh - 64px - 92px - 32px - 16px);
  360. > svg {
  361. height: 100%;
  362. width: 100%;
  363. }
  364. }
  365. </style>