mirror of https://github.com/Requarks/wiki.git
NGPixel
4 years ago
10 changed files with 709 additions and 15 deletions
Split 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