|
@@ -0,0 +1,358 @@
|
|
|
+<template lang='pug'>
|
|
|
+ v-container(fluid, grid-list-lg)
|
|
|
+ v-layout(row wrap)
|
|
|
+ v-flex(xs12)
|
|
|
+ .admin-header
|
|
|
+ img.animated.fadeInUp(src='/svg/icon-venn-diagram.svg', alt='Visualize Pages', style='width: 80px;')
|
|
|
+ .admin-header-title
|
|
|
+ .headline.blue--text.text--darken-2.animated.fadeInLeft Visualize Pages
|
|
|
+ .subtitle-1.grey--text.animated.fadeInLeft.wait-p2s Dendrogram representation of your pages
|
|
|
+ v-spacer
|
|
|
+ v-select.mx-5.animated.fadeInDown.wait-p1s(
|
|
|
+ v-if='locales.length > 0'
|
|
|
+ v-model='currentLocale'
|
|
|
+ :items='locales'
|
|
|
+ style='flex: 0 1 120px;'
|
|
|
+ solo
|
|
|
+ dense
|
|
|
+ hide-details
|
|
|
+ item-value='code'
|
|
|
+ item-text='name'
|
|
|
+ )
|
|
|
+ v-btn-toggle.animated.fadeInDown(v-model='graphMode', color='primary', dense, rounded)
|
|
|
+ v-btn.px-5(value='htree')
|
|
|
+ v-icon(left, :color='graphMode === `htree` ? `primary` : `grey darken-3`') mdi-sitemap
|
|
|
+ span.text-none Hierarchical Tree
|
|
|
+ v-btn.px-5(value='hradial')
|
|
|
+ v-icon(left, :color='graphMode === `hradial` ? `primary` : `grey darken-3`') mdi-chart-donut-variant
|
|
|
+ span.text-none Hierarchical Radial
|
|
|
+ v-btn.px-5(value='rradial')
|
|
|
+ v-icon(left, :color='graphMode === `rradial` ? `primary` : `grey darken-3`') mdi-blur-radial
|
|
|
+ span.text-none Relational Radial
|
|
|
+ v-chip.ml-3(x-small) Beta
|
|
|
+ .admin-pages-visualize-svg.pa-10(ref='svgContainer')
|
|
|
+ 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!
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import _ from 'lodash'
|
|
|
+import * as d3 from 'd3'
|
|
|
+import gql from 'graphql-tag'
|
|
|
+
|
|
|
+/* global siteConfig, siteLangs */
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ graphMode: 'htree',
|
|
|
+ width: 800,
|
|
|
+ radius: 400,
|
|
|
+ pages: [],
|
|
|
+ locales: siteLangs,
|
|
|
+ currentLocale: siteConfig.lang
|
|
|
+ }
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ pages () {
|
|
|
+ this.redraw()
|
|
|
+ },
|
|
|
+ graphMode () {
|
|
|
+ this.redraw()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ bilink (root) {
|
|
|
+ const map = new Map(root.leaves().map(d => [d.data.path, d]))
|
|
|
+ for (const d of root.leaves()) {
|
|
|
+ d.incoming = []
|
|
|
+ d.outgoing = []
|
|
|
+ d.data.links.forEach(i => {
|
|
|
+ const relNode = map.get(i)
|
|
|
+ if (relNode) {
|
|
|
+ d.outgoing.push([d, relNode])
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ for (const d of root.leaves()) {
|
|
|
+ for (const o of d.outgoing) {
|
|
|
+ if (o[1]) {
|
|
|
+ o[1].incoming.push(o)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return root
|
|
|
+ },
|
|
|
+ hierarchy (data, rootOnly = false) {
|
|
|
+ let result = []
|
|
|
+ let level = { result }
|
|
|
+ const map = new Map(data.map(d => [d.path, d]))
|
|
|
+ data.forEach(d => {
|
|
|
+ const pathParts = d.path.split('/')
|
|
|
+ pathParts.reduce((r, part, i) => {
|
|
|
+ const curPath = _.take(pathParts, i + 1).join('/')
|
|
|
+ if (!r[part]) {
|
|
|
+ r[part] = { result: [] }
|
|
|
+ const page = map.get(curPath)
|
|
|
+ r.result.push(page ? {
|
|
|
+ ...d,
|
|
|
+ children: r[part].result
|
|
|
+ } : {
|
|
|
+ title: part,
|
|
|
+ links: [],
|
|
|
+ path: curPath,
|
|
|
+ children: r[part].result
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return r[part]
|
|
|
+ }, level)
|
|
|
+ })
|
|
|
+
|
|
|
+ return rootOnly ? _.head(result) || { children: [] } : {
|
|
|
+ children: result
|
|
|
+ }
|
|
|
+ },
|
|
|
+ drawRelations () {
|
|
|
+ const data = this.hierarchy(this.pages)
|
|
|
+
|
|
|
+ const line = d3.lineRadial()
|
|
|
+ .curve(d3.curveBundle.beta(0.85))
|
|
|
+ .radius(d => d.y)
|
|
|
+ .angle(d => d.x)
|
|
|
+
|
|
|
+ const tree = d3.cluster()
|
|
|
+ .size([2 * Math.PI, this.radius - 100])
|
|
|
+
|
|
|
+ const root = tree(this.bilink(d3.hierarchy(data)
|
|
|
+ .sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.title, b.data.title))))
|
|
|
+
|
|
|
+ const svg = d3.create('svg')
|
|
|
+ .attr('viewBox', [-this.width / 2, -this.width / 2, this.width, this.width])
|
|
|
+
|
|
|
+ svg.append('g')
|
|
|
+ .attr('font-family', 'sans-serif')
|
|
|
+ .attr('font-size', 10)
|
|
|
+ .selectAll('g')
|
|
|
+ .data(root.leaves())
|
|
|
+ .join('g')
|
|
|
+ .attr('transform', d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
|
|
|
+ .append('text')
|
|
|
+ .attr('dy', '0.31em')
|
|
|
+ .attr('x', d => d.x < Math.PI ? 6 : -6)
|
|
|
+ .attr('text-anchor', d => d.x < Math.PI ? 'start' : 'end')
|
|
|
+ .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
|
|
|
+ .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
|
|
|
+ .text(d => d.data.title)
|
|
|
+ .each(function(d) { d.text = this })
|
|
|
+ .on('mouseover', overed)
|
|
|
+ .on('mouseout', outed)
|
|
|
+ .call(text => text.append('title').text(d => `${d.data.path}
|
|
|
+ ${d.outgoing.length} outgoing
|
|
|
+ ${d.incoming.length} incoming`))
|
|
|
+
|
|
|
+ const link = svg.append('g')
|
|
|
+ .attr('stroke', '#CCC')
|
|
|
+ .attr('fill', 'none')
|
|
|
+ .selectAll('path')
|
|
|
+ .data(root.leaves().flatMap(leaf => leaf.outgoing))
|
|
|
+ .join('path')
|
|
|
+ .style('mix-blend-mode', 'multiply')
|
|
|
+ .attr('d', ([i, o]) => line(i.path(o)))
|
|
|
+ .each(function(d) { d.path = this })
|
|
|
+
|
|
|
+ function overed(d) {
|
|
|
+ link.style('mix-blend-mode', null)
|
|
|
+ d3.select(this).attr('font-weight', 'bold')
|
|
|
+ d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', '#2196F3').raise()
|
|
|
+ d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', '#2196F3').attr('font-weight', 'bold')
|
|
|
+ d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', '#E91E63').raise()
|
|
|
+ d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', '#E91E63').attr('font-weight', 'bold')
|
|
|
+ }
|
|
|
+
|
|
|
+ function outed(d) {
|
|
|
+ link.style('mix-blend-mode', 'multiply')
|
|
|
+ d3.select(this).attr('font-weight', null)
|
|
|
+ d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', null)
|
|
|
+ d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', null).attr('font-weight', null)
|
|
|
+ d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', null)
|
|
|
+ d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', null).attr('font-weight', null)
|
|
|
+ }
|
|
|
+
|
|
|
+ this.$refs.svgContainer.appendChild(svg.node())
|
|
|
+ },
|
|
|
+ drawTree () {
|
|
|
+ const data = this.hierarchy(this.pages, true)
|
|
|
+
|
|
|
+ const treeRoot = d3.hierarchy(data)
|
|
|
+ treeRoot.dx = 10
|
|
|
+ treeRoot.dy = this.width / (treeRoot.height + 1)
|
|
|
+ const root = d3.tree().nodeSize([treeRoot.dx, treeRoot.dy])(treeRoot)
|
|
|
+
|
|
|
+ let x0 = Infinity
|
|
|
+ let x1 = -x0
|
|
|
+ root.each(d => {
|
|
|
+ if (d.x > x1) x1 = d.x
|
|
|
+ if (d.x < x0) x0 = d.x
|
|
|
+ })
|
|
|
+
|
|
|
+ const svg = d3.create('svg')
|
|
|
+ .attr('viewBox', [0, 0, this.width, x1 - x0 + root.dx * 2])
|
|
|
+
|
|
|
+ const g = svg.append('g')
|
|
|
+ .attr('font-family', 'sans-serif')
|
|
|
+ .attr('font-size', 10)
|
|
|
+ .attr('transform', `translate(${root.dy / 3},${root.dx - x0})`)
|
|
|
+
|
|
|
+ g.append('g')
|
|
|
+ .attr('fill', 'none')
|
|
|
+ .attr('stroke', this.$vuetify.theme.dark ? '#999' : '#555')
|
|
|
+ .attr('stroke-opacity', 0.4)
|
|
|
+ .attr('stroke-width', 1.5)
|
|
|
+ .selectAll('path')
|
|
|
+ .data(root.links())
|
|
|
+ .join('path')
|
|
|
+ .attr('d', d3.linkHorizontal()
|
|
|
+ .x(d => d.y)
|
|
|
+ .y(d => d.x))
|
|
|
+
|
|
|
+ const node = g.append('g')
|
|
|
+ .attr('stroke-linejoin', 'round')
|
|
|
+ .attr('stroke-width', 3)
|
|
|
+ .selectAll('g')
|
|
|
+ .data(root.descendants())
|
|
|
+ .join('g')
|
|
|
+ .attr('transform', d => `translate(${d.y},${d.x})`)
|
|
|
+
|
|
|
+ node.append('circle')
|
|
|
+ .attr('fill', d => d.children ? '#555' : '#999')
|
|
|
+ .attr('r', 2.5)
|
|
|
+
|
|
|
+ node.append('text')
|
|
|
+ .attr('dy', '0.31em')
|
|
|
+ .attr('x', d => d.children ? -6 : 6)
|
|
|
+ .attr('text-anchor', d => d.children ? 'end' : 'start')
|
|
|
+ .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
|
|
|
+ .text(d => d.data.title)
|
|
|
+ .clone(true).lower()
|
|
|
+ .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
|
|
|
+
|
|
|
+ this.$refs.svgContainer.appendChild(svg.node())
|
|
|
+ },
|
|
|
+ drawRadialTree () {
|
|
|
+ const data = this.hierarchy(this.pages)
|
|
|
+
|
|
|
+ const tree = d3.tree()
|
|
|
+ .size([2 * Math.PI, this.radius])
|
|
|
+ .separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth)
|
|
|
+
|
|
|
+ const root = tree(d3.hierarchy(data)
|
|
|
+ .sort((a, b) => d3.ascending(a.data.title, b.data.title)))
|
|
|
+
|
|
|
+ const svg = d3.create('svg')
|
|
|
+ .style('font', '10px sans-serif')
|
|
|
+
|
|
|
+ svg.append('g')
|
|
|
+ .attr('fill', 'none')
|
|
|
+ .attr('stroke', this.$vuetify.theme.dark ? 'white' : '#555')
|
|
|
+ .attr('stroke-opacity', 0.4)
|
|
|
+ .attr('stroke-width', 1.5)
|
|
|
+ .selectAll('path')
|
|
|
+ .data(root.links())
|
|
|
+ .join('path')
|
|
|
+ .attr('d', d3.linkRadial()
|
|
|
+ .angle(d => d.x)
|
|
|
+ .radius(d => d.y))
|
|
|
+
|
|
|
+ const node = svg.append('g')
|
|
|
+ .attr('stroke-linejoin', 'round')
|
|
|
+ .attr('stroke-width', 3)
|
|
|
+ .selectAll('g')
|
|
|
+ .data(root.descendants().reverse())
|
|
|
+ .join('g')
|
|
|
+ .attr('transform', d => `
|
|
|
+ rotate(${d.x * 180 / Math.PI - 90})
|
|
|
+ translate(${d.y},0)
|
|
|
+ `)
|
|
|
+
|
|
|
+ node.append('circle')
|
|
|
+ .attr('fill', d => d.children ? '#555' : '#999')
|
|
|
+ .attr('r', 2.5)
|
|
|
+
|
|
|
+ node.append('text')
|
|
|
+ .attr('dy', '0.31em')
|
|
|
+ /* eslint-disable no-mixed-operators */
|
|
|
+ .attr('x', d => d.x < Math.PI === !d.children ? 6 : -6)
|
|
|
+ .attr('text-anchor', d => d.x < Math.PI === !d.children ? 'start' : 'end')
|
|
|
+ .attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
|
|
|
+ /* eslint-enable no-mixed-operators */
|
|
|
+ .attr('fill', this.$vuetify.theme.dark ? 'white' : '')
|
|
|
+ .text(d => d.data.title)
|
|
|
+ .clone(true).lower()
|
|
|
+ .attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
|
|
|
+
|
|
|
+ this.$refs.svgContainer.appendChild(svg.node())
|
|
|
+
|
|
|
+ function autoBox() {
|
|
|
+ const {x, y, width, height} = this.getBBox()
|
|
|
+ return [x, y, width, height]
|
|
|
+ }
|
|
|
+
|
|
|
+ svg.attr('viewBox', autoBox)
|
|
|
+ },
|
|
|
+ redraw () {
|
|
|
+ while (this.$refs.svgContainer.firstChild) {
|
|
|
+ this.$refs.svgContainer.firstChild.remove()
|
|
|
+ }
|
|
|
+ if (this.pages.length > 0) {
|
|
|
+ switch (this.graphMode) {
|
|
|
+ case 'rradial':
|
|
|
+ this.drawRelations()
|
|
|
+ break
|
|
|
+ case 'htree':
|
|
|
+ this.drawTree()
|
|
|
+ break
|
|
|
+ case 'hradial':
|
|
|
+ this.drawRadialTree()
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ apollo: {
|
|
|
+ pages: {
|
|
|
+ query: gql`
|
|
|
+ query ($locale: String!) {
|
|
|
+ pages {
|
|
|
+ links(locale: $locale) {
|
|
|
+ id
|
|
|
+ path
|
|
|
+ title
|
|
|
+ links
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ `,
|
|
|
+ variables () {
|
|
|
+ return {
|
|
|
+ locale: this.currentLocale
|
|
|
+ }
|
|
|
+ },
|
|
|
+ fetchPolicy: 'network-only',
|
|
|
+ update: (data) => data.pages.links,
|
|
|
+ watchLoading (isLoading) {
|
|
|
+ this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-pages-refresh')
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang='scss'>
|
|
|
+.admin-pages-visualize-svg {
|
|
|
+ text-align: center;
|
|
|
+
|
|
|
+ > svg {
|
|
|
+ height: 100vh;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|