mirror of https://github.com/Requarks/wiki.git
10 changed files with 709 additions and 15 deletions
Unified View
Diff Options
-
7client/components/admin.vue
-
358client/components/admin/admin-pages-visualize.vue
-
6client/components/admin/admin-pages.vue
-
20client/components/editor/editor-modal-properties.vue
-
1package.json
-
46server/graph/resolvers/page.js
-
15server/graph/schemas/page.graphql
-
4server/helpers/page.js
-
7server/modules/storage/disk/common.js
-
260yarn.lock
@ -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> |
Write
Preview
Loading…
Cancel
Save