| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405 | <template lang='pug'>  v-container(fluid, grid-list-lg)    v-layout(row wrap)      v-flex(xs12)        .admin-header          img.animated.fadeInUp(src='/_assets/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(ref='svgContainer', v-show='pages.length >= 1')        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) {      const id = d.data.id      if (id) {        if (d3.event.ctrlKey || d3.event.metaKey) {          const { href } = this.$router.resolve(String(id))          window.open(href, '_blank')        } else {          this.$router.push(String(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 (pages) {      const map = new Map(pages.map(p => [p.path, p]))      const getPage = path => map.get(path) || {        path: path,        title: path.split('/').slice(-1)[0],        links: []      }      function recurse (depth, [parent, descendants]) {        const truncatePath = path => _.take(path.split('/'), depth).join('/')        const descendantsByChild =          Object.entries(_.groupBy(descendants, page => truncatePath(page.path)))            .map(([childPath, descendantsGroup]) => [getPage(childPath), descendantsGroup])            .map(([child, descendantsGroup]) =>              [child, _.filter(descendantsGroup, d => d.path !== child.path)])        return {          ...parent,          children: descendantsByChild.map(_.partial(recurse, depth + 1))        }      }      const root = { path: this.currentLocale, title: this.currentLocale, links: [] }      // start at depth=2 because we're taking {locale} as the root and      // all paths start with {locale}/      return recurse(2, [root, pages])    },    /**     * Relational Radial     */    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.path, b.data.path))))      const svg = d3.create('svg')        .attr('viewBox', [-this.width / 2, -this.width / 2, this.width, this.width])      const g = svg.append('g')      svg.call(d3.zoom().on('zoom', function() {        g.attr('transform', d3.event.transform)      }))      const link = g.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 })      g.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)      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])      // this extra level is necessary because the element that we      // apply the zoom tranform to must be above the element where      // we apply the translation (`g`), or else zoom is wonky      const gZoom = svg.append('g')      svg.call(d3.zoom().on('zoom', function() {        gZoom.attr('transform', d3.event.transform)      }))      const g = gZoom.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')      const g = svg.append('g')      svg.call(d3.zoom().on('zoom', function () {        g.attr('transform', d3.event.transform)      }))      // eslint-disable-next-line no-unused-vars      const link = g.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 = g.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;  // 100vh - header - title section - footer - content padding  height: calc(100vh - 64px - 92px - 32px - 16px);  > svg {    height: 100%;    width: 100%;  }}</style>
 |