wikijs-fork/client/components/admin/admin-pages-visualize.vue
2020-01-11 22:33:27 -05:00

380 lines
12 KiB
Vue

<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
.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: {
goToPage (d) {
if (_.get(d, 'data.id', 0) > 0) {
this.$router.push(`${d.data.id}`)
}
},
bilink (root) {
const map = new Map(root.descendants().map(d => [d.data.path, d]))
for (const d of root.descendants()) {
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.descendants()) {
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
}
},
/**
* Relational Radial
*/
drawRelations () {
const data = this.hierarchy(this.pages, true)
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.path, b.data.path))))
const svg = d3.create('svg')
.attr('viewBox', [-this.width / 2, -this.width / 2, this.width, this.width])
const link = svg.append('g')
.attr('stroke', '#CCC')
.attr('fill', 'none')
.selectAll('path')
.data(root.descendants().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 })
svg.append('g')
.attr('font-family', 'sans-serif')
.attr('font-size', 10)
.selectAll('g')
.data(root.descendants())
.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' : '')
.attr('cursor', 'pointer')
.text(d => d.data.title)
.each(function(d) { d.text = this })
.on('mouseover', overed)
.on('mouseout', outed)
.on('click', d => this.goToPage(d))
.call(text => text.append('title').text(d => `${d.data.path}
${d.outgoing.length} outgoing
${d.incoming.length} incoming`))
.clone(true).lower()
.attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
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())
},
/**
* Hierarchical Tree
*/
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' : '')
.attr('cursor', 'pointer')
.text(d => d.data.title)
.on('click', d => this.goToPage(d))
.clone(true).lower()
.attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
this.$refs.svgContainer.appendChild(svg.node())
},
/**
* Hierarchical Radial
*/
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' : '')
.attr('cursor', 'pointer')
.text(d => d.data.title)
.on('click', d => this.goToPage(d))
.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>