Commit 99c4a317 authored by Marko Řeháček's avatar Marko Řeháček
Browse files

Bundling (WIP).

Store/data: add actions for aliasing and bundling, rename aggr. mutations, remove deprecated code, remove isUserDefined when creating aggregations, add tests for bundling (not passing), remove isUserDefined.

NodeSelection: add test UI for aggregations.
GraphNode: add bundle visuals, get bundle size from store.
NetworkGraph: tweak settings, renamed store actions.
parent c44ef44c
Loading
Loading
Loading
Loading
+30 −24
Original line number Diff line number Diff line
@@ -33,8 +33,8 @@

    <svg
      id="network-graph"
      :width="graph.width"
      :height="graph.height"
      :width="$vssWidth"
      :height="$vssHeight"
      :class="{ panning: graph.isPanningOrZooming }"
    >
      <defs>
@@ -140,6 +140,7 @@
import Vue from 'vue'
import { mapActions, mapMutations } from 'vuex'
import * as d3 from 'd3' // TODO: import only needed modules
import VueScreenSize from 'vue-screen-size'

import GraphNode from '@/components/NetworkGraph/GraphNode.vue'
import GraphLink from '@/components/NetworkGraph/GraphLink.vue'
@@ -152,6 +153,8 @@ export default {
    GraphLinkMarker
  },

  mixins: [VueScreenSize.VueScreenSizeMixin],

  data() {
    return {
      data: {
@@ -233,10 +236,10 @@ export default {
        case 'data/REMOVE_NODES':
          this.respondRemoveNodes(pl)
          break
        case 'data/GROUP_NODES':
          this.respondGroupNode(pl)
        case 'data/AGGREGATE_NODES':
          this.respondAggregateNodes(pl)
          break
        case 'data/UNGROUP_NODE':
        case 'data/DISAGGREGATE_NODE':
          this.respondUngroupNode(pl)
          break
        case 'data/INIT':
@@ -283,8 +286,8 @@ export default {
      Vue.set(this.data, 'links', this.data.links.filter(l => !removedLinkIds.includes(l.id)))
    },

    respondGroupNode(payload) {
      console.log('NetworkGraph: data/GROUP_NODES with payload:' + JSON.stringify(payload))
    respondAggregateNodes(payload) {
      console.log('NetworkGraph: data/AGGREGATE_NODES with payload:' + JSON.stringify(payload))
      const nodesIdArr = payload.nodeIds
      const groupNode = payload.targetGroupNode
      const newLinksArr = payload.newVisibleLinks
@@ -297,6 +300,7 @@ export default {
      // remove nodes
      Vue.set(this.data, 'nodes', this.data.nodes.filter(n => !nodesIdArr.includes(n.id)))

      if (groupNode.type === 'group' || groupNode.type === 'alias') {
        // create the new group node
        this.data.nodes.push({
          id: groupNode.id,
@@ -306,10 +310,11 @@ export default {
          vy: 0,
          index: this.getNewNodeId()
        })
      }

      // create new links
      for (const link of newLinksArr) {
        console.log('respondGroupNode: creating link ' + JSON.stringify(link))
        console.log('respondAggregateNodes: creating link ' + JSON.stringify(link))
        this.data.links.push({
          id: link.id,
          source: this.findNode(link.source),
@@ -319,13 +324,14 @@ export default {
    },

    respondUngroupNode(payload) {
      console.log('NetworkGraph: data/UNGROUP_NODE with payload:' + JSON.stringify(payload))
      console.log('NetworkGraph: data/DISAGGREGATE_NODE with payload:' + JSON.stringify(payload))
      const groupId = payload.nodeId
      const newLinks = payload.newVisibleLinks
      const newNodes = payload.newVisibleNodes
      const removedLinkIds = payload.removedLinkIds

      // remove the group node
      if (payload.removeNode)
        Vue.set(this.data, 'nodes', this.data.nodes.filter(n => n.id !== groupId))
      // remove group links
      Vue.set(this.data, 'links', this.data.links.filter(l => !removedLinkIds.includes(l.id)))
@@ -391,22 +397,22 @@ export default {
          d3
            .forceLink(this.data.links)
            .id(link => link.id)
            .distance(() => 80)
            .distance(() => 120)
        )
        .force(
          'charge',
          d3
            .forceManyBody()
            .strength(-30)
            .distanceMin(10)
            .distanceMax(Infinity)
            .strength(-3000)
            .distanceMin(20)
            .distanceMax(500)
        ) // electrostatic force between all nodes
        .force('center', d3.forceCenter(this.graph.width / 2, this.graph.height / 2)) // translate to svg center
        .force(
          'colide',
          d3
            .forceCollide()
            .radius(80)
            .radius(70)
            .strength(0.7)
        )
        .on('end', () => {
+53 −9
Original line number Diff line number Diff line
@@ -8,11 +8,11 @@
      <g v-if="!isRemoved && !inDebug" class="node-label">
        <rect
          class="node-label-box"
          width="135"
          height="20"
          :width="150"
          height="22"
          rx="5"
          ry="5"
          y="-10"
          y="-12"
          :x="node.type == 'group' ? visuals.node.group_circles_rad_diff / 2.0 : 0"
        />
        <text
@@ -21,8 +21,7 @@
          dx="24"
          dy="4"
          :x="node.type == 'group' ? visuals.node.group_circles_rad_diff : 0"
          >{{ node.label }}</text
        >
        >{{ node.label }}</text>
      </g>

      <g
@@ -46,6 +45,32 @@
          class="node-circle-focused-and-selected"
          :r="visCircleRadius"
        />

        <!-- alias -->
        <circle
          v-if="node.type === 'alias'"
          class="node-circle"
          :cx="-visCircleRadius / 2.0"
          :cy="-visCircleRadius / 2.0"
          :r="visCircleRadius"
        />

        <!-- bundle -->
        <g class="bundle-bubble" v-if="node.type === 'bundle'">
          <circle
            class="node-circle bundle-circle"
            :r="visCircleRadius / 3.0"
            :cx="visCircleRadius + 2"
            :cy="-visCircleRadius - 2"
          ></circle>
          <text
            class="bundle-text"
            text-anchor="middle"
            :x="visCircleRadius + 2"
            :y="-visCircleRadius + 2"
          >{{ bundleSize }}</text>
        </g>

        <circle
          :id="`node-circle-${node.id}`"
          class="node-circle"
@@ -128,13 +153,19 @@ export default {
      if (this.isFocused && this.isSelected) return this.visuals.node.circleRadius + 1
      if (this.isRemoved) return this.visuals.node.circleRadius
      return this.visuals.node.circleRadius
    },
    bundleSize() {
      if (this.node.type !== 'bundle') throw Error("Not a bundle")
      return this.$store.getters['data/replacedNodesCount'](this.dataId)
    }
  },

  watch: {
    node: function(n) {
      if (n === undefined) vm.$destroy()
      if (!n) throw Error('Graph is trying to display unknown node')
      // Node may be removed from dataset. However, if we have fade animation,
      // it would still trigger user events, which would call data storage and request
      // a removed node, resulting in error. So we remove the component instance beforehand.
      if (n === undefined) this.vm.$destroy()
    }
  },

@@ -216,12 +247,12 @@ export default {
  fill: #f2f2f2

.node-label-text
  font-size: 9pt
  font-size: 0.85em
  fill: #666
  display: inline

.node-label-id
  font-size: 18pt
  font-size: 1.3em
  font-weight: 600
  fill: #4903fc
  user-select: none
@@ -239,4 +270,17 @@ export default {

.node-circle.connected-link-hovered
  stroke: #757575

.bundle .bundle-bubble
  display: inline

.bundle-circle 
  stroke-width: 1.5px
  stroke: #D8D8D8
  fill: #fff

.bundle-text 
  font-size: 0.65em
  font-weight: 600
  fill: #666  
</style>
+6 −1
Original line number Diff line number Diff line
@@ -46,7 +46,7 @@
          >Remove</b-button
        >
        <b-button
          v-if="node.type === 'group'"
          v-if="isAggregated"
          plain
          @click="
            ungroupNode(node.id)
@@ -61,12 +61,17 @@

<script>
import { mapState, mapGetters, mapActions } from 'vuex'
import { isNodeAggregated } from '@/store/modules/data.js'

export default {
  computed: {
    node() {
      return this.$store.getters['data/getNodeById'](this.focusedNodeId)
    },
    isAggregated() {
      return isNodeAggregated(this.node)
      //return this.node.type === 'group' || this.node.type === 'alias' || this.node.type === 'bundle'
    },
    ...mapState('interaction', ['focusedNodeId']),
    ...mapGetters('interaction', ['isShownNodeDetail'])
  },
+46 −1
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@
        </div>
        <br />

        <div class="buttons">
        <b-button
          :disabled="selectedNodes.length > 1 ? false : true"
          plain
@@ -36,6 +37,27 @@
          "
          >Make group</b-button
        >

        <b-button
          :disabled="selectedNodes.length > 1 ? false : true"
          plain
          @click="
            aliasNodes(selectedNodes)
            clearCloseNodeSelection()
          "
          >Make alias</b-button
        >

        <span v-if="isSelectingRepresentativeNode"><br />Select representative node for bundle or <a @click="isSelectingRepresentativeNode = false">cancel</a>.</span>
        <b-button
          v-else
          :disabled="selectedNodes.length === 0"
          plain
          @click="prepareBundle()"
          >Bundle to …</b-button
        >
        </div>

      </div>
    </div>
  </b-collapse>
@@ -45,6 +67,13 @@
import { mapState, mapGetters, mapActions } from 'vuex'

export default {
  data() {
    return {
      isSelectingRepresentativeNode: false,
      previouslySelectedNodes: []
    }
  },

  computed: {
    ...mapState('interaction', ['selectedNodes']),
    ...mapGetters('interaction', ['isShownNodeSelection'])
@@ -52,7 +81,23 @@ export default {

  methods: {
    ...mapActions('interaction', ['clearCloseNodeSelection']),
    ...mapActions('data', ['groupNodes'])
    ...mapActions('data', ['groupNodes', 'aliasNodes', 'bundleNodes']),

    prepareBundle() {
      this.isSelectingRepresentativeNode = true
      this.previouslySelectedNodes = []
      this.selectedNodes.forEach(id => this.previouslySelectedNodes.push(id))
    }
  },

  mounted() {
    this.$store.subscribe(mutation => {
      if (mutation.type === 'interaction/SELECT_NODE' && this.isSelectingRepresentativeNode) {
        this.bundleNodes({representativeNodeId: mutation.payload, nodeIds: this.previouslySelectedNodes})
        this.isSelectingRepresentativeNode = false
        this.clearCloseNodeSelection()
      }
    })
  }
}
</script>
+88 −68
Original line number Diff line number Diff line
import Vue from 'vue'
import mockData from '@/datasets/data.js'
import mockData from '@/datasets/data-small.js'
import { cloneDeep, merge, sortBy } from 'lodash'

/**
@@ -7,9 +7,9 @@ import { cloneDeep, merge, sortBy } from 'lodash'
 *
 * @property {number} id dataset id
 * @property {('regular'|'group'|'bundle'|'alias')} type
 * @property {string} dataClass like person, document, etc.
 * @property {string} name
 * @property {string} label
 * @property {string} [dataClass] like person, document, etc.
 * @property {string} [name]
 * @property {string} [label]
 *
 * @property {NodeVis} [vis] storage for visualization data
 *
@@ -56,6 +56,9 @@ export const state = {
  links: []
}

const NODE_AGGREGATION_TYPES = ['group', 'alias', 'bundle']
Object.freeze(NODE_AGGREGATION_TYPES)

function filterVisibleLinks(links) {
  return links.filter(l => isLinkVisible(l))
}
@@ -64,6 +67,10 @@ export function filterVisibleNodes(nodes) {
  return nodes.filter(n => !n.vis || !n.vis.replacedBy)
}

export function isNodeAggregated(n) {
  return NODE_AGGREGATION_TYPES.includes(n.type)
}

/**
 * Check if visible: not in multilink, not an archived multilink, newTarget != newSource
 * @param {Link} l
@@ -139,8 +146,7 @@ function insertUpdatedLink(arr, link, groupId, newId) {
    const ml = createMultilink(
      newId,
      isSourceGroup ? link.vis.newSource : link.vis.newTarget,
      isSourceGroup ? link.vis.newTarget : link.vis.newSource,
      true
      isSourceGroup ? link.vis.newTarget : link.vis.newSource
    )
    const merging = [similarLink, link]
    merging.forEach(l => {
@@ -258,20 +264,6 @@ function multilinkUpdateDirection(/* const */ arr, ml) {
    })
}

/**
 * FIXME: deprecated
 *
 * Check if element is in aggregation
 * @param {Object} el either node or link
 * @param {Number[]} [ids] if given, also check for specific aggregation
 * @return {Boolean}
 */
export function isInAggregation(el, ids = []) {
  if (!el.vis) return false
  if (ids.length === 0) return el.vis.replacedBy
  return ids.includes(el.vis.replacedBy)
}

/**
 * Check if element has replacedBy attribute, ie. is in aggregation
 * @param {Node} node
@@ -305,13 +297,16 @@ function isMultilink(link) {
 * Push new group node to state
 * @param {Object} state writes to state.nodes
 * @param {Node} node will be assigned type and new ID
 * @param {boolean} isUserDefined true to set this vis attr
 */
function createGroupNode(state, node) {
function pushNodeToState(state, node, isUserDefined) {
  node.id = arrGetNewId(state.nodes)
  node.type = 'group'
  if (isUserDefined) {
    node.vis = node.vis || {}
    node.vis.isUserDefined = true
  }
  state.nodes.push(cloneDeep(node))
  return node.id
}

/**
@@ -354,11 +349,12 @@ function arraySetVisAttrs(arr, visAttrs) {
 * @param {number} target
 */
function createMultilink(newId, source, target) {
  // FIXME: when isUserDefined?
  return {
    id: newId,
    source: source,
    target: target,
    vis: { isMultilink: true, isUserDefined: true }
    vis: { isMultilink: true }
  }
}

@@ -481,8 +477,8 @@ export const mutations = {
  },

  /**
   * Aggregate nodes in general group
   * Pushe new group node/links to state, update nodes/links
   * Aggregate nodes in general group, alias or bundle
   * Push new group node/links to state, update nodes/links.
   *
   * Aggregation types
   * ALIAS
@@ -498,29 +494,30 @@ export const mutations = {
   *
   * @param {Object}   state
   * @param {Object}   payload
   * @param {Number[]} payload.nodeIds to group
   * @param {Object}   payload.targetGroupNode ref. to the new group node without ID, which will be set
   * @param {Number[]} payload.nodeIds to aggregate
   * @param {Node}     [payload.targetGroupNode] ref. to the new group node without ID, which will be set
   * @param {Number[]} [payload.removedLinkIds] OUT: will contain links hidden during aggregation, ordered from smallest
   * @param {Object[]} [payload.newVisibleLinks] OUT: will contain new links, ordered by id from smallest
   *
   * @throws {Error} when nodeIds are not given or less than 2
   * @throws {Error} when targetGroupNode already has an ID or is just regular node
   */
  GROUP_NODES(state, payload) {
  AGGREGATE_NODES(state, payload) {
    const nodesToGroupIds = payload.nodeIds,
      groupNode = payload.targetGroupNode,
      removedLinkIds = [],
      newVisibleLinks = []

    if (!nodesToGroupIds || nodesToGroupIds.length < 2)
      throw Error(' Cannot group less then 2 nodes')
    if (!groupNode) throw Error('Group node is undefined')
    if (groupNode.type === 'regular') throw Error('Node is regular.')
    if (groupNode.id) throw Error('Group node should not come with an ID')
    if (groupNode.type !== 'bundle' && (!nodesToGroupIds || nodesToGroupIds.length < 2))
      throw Error('Grouping requires less then 2 nodes')
    if (!groupNode)
      throw Error('Group node is undefined')
    if (groupNode.type === 'regular')
      throw Error('Node is regular.')
    if (groupNode.type !== 'bundle' && groupNode.id !== undefined)
      throw Error('Group node should not come with an ID')

    createGroupNode(state, groupNode)
    const groupId = groupNode.id
    // find nodes in state and set their replacedBy attribute
    const groupId = groupNode.type === 'bundle' ? groupNode.id : pushNodeToState(state, groupNode, false)
    arraySetVisAttrs(state.nodes.filter(n => nodesToGroupIds.includes(n.id)), {
      replacedBy: groupId
    })
@@ -564,21 +561,12 @@ export const mutations = {
      // link was already transferred -> replace newSource, newTarget
      if (isGroupNewTarget || isGroupNewSource) {
        if (isSourceAndTargetSame) continue

        if (isGroupNewTarget && !isGroupNewSource) {
          source = link.vis.newSource
        }
        if (!isGroupNewTarget && isGroupNewSource) {
          target = link.vis.newTarget
        }
        if (isGroupNewTarget && !isGroupNewSource) source = link.vis.newSource
        if (!isGroupNewTarget && isGroupNewSource) target = link.vis.newTarget
        // link is new -> set newSource, newTarget for the first time
      } else if (!isMultilink(link) && (isGroupTarget || isGroupSource)) {
        if (isGroupTarget && !isGroupSource) {
          source = link.source
        }
        if (!isGroupTarget && isGroupSource) {
          target = link.target
        }
        if (isGroupTarget && !isGroupSource) source = link.source
        if (!isGroupTarget && isGroupSource) target = link.target
      } else if (isMultilink(link)) {
        link.vis.isArchived = true
        continue
@@ -627,13 +615,14 @@ export const mutations = {
  },

  /**
   * Reverse general grouping
   * Reverse node aggregation for group, alias or bundle
   *
   * New data for graph will be placed in payload object.
   *
   * @param {Object}   state
   * @param {Object}   payload
   * @param {Number}   payload.nodeId group node to ungroup
   * @param {boolean}  [payload.removeNode] OUT: will contain instruction whenever to remove the ungroped node
   * @param {Number[]} [payload.removedLinkIds] OUT: will contain removed links, ordered from smallest
   * @param {Object[]} [payload.newVisibleLinks] OUT: will contain replacement links for graph, ordered by ids from smallest
   * @param {Object[]} [payload.newVisibleNodes] OUT: will contain replacement nodes for graph, ordered by ids from smallest
@@ -641,7 +630,7 @@ export const mutations = {
   * @throws {Error} when given node does not exist or is not a group
   * @throws {Error} when given node is not user created
   */
  UNGROUP_NODE(state, payload) {
  DISAGGREGATE_NODE(state, payload) {
    const groupId = payload.nodeId,
      removedVisibleLinkIds = [],
      newVisibleLinks = [],
@@ -650,9 +639,8 @@ export const mutations = {
    if (typeof groupId !== 'number' || groupId < 0) throw Error('Invalid argument.')
    const groupNode = state.nodes.find(n => n.id === groupId)
    if (!groupNode) throw Error('Node is undefined.')
    if (groupNode.type !== 'group') throw Error('Node is not a group.')
    if (!groupNode.vis || groupNode.vis.isUserDefined !== true)
      throw Error('Cannot modify original dataset.')
    if (!isNodeAggregated(groupNode)) throw Error('Node is not a group.')
    payload.removeNode = groupNode.type !== 'bundle'

    // reveal original nodes
    const revealedNodes = state.nodes.filter(n => isReplacedBy(n, groupId))
@@ -666,6 +654,7 @@ export const mutations = {
      )
    })
    // remove group node
    if (groupNode.type !== 'bundle')
      Vue.set(state, 'nodes', state.nodes.filter(n => n.id !== groupId))

    // convert state.nodes to hash map, because we will be doing many recursive
@@ -830,25 +819,52 @@ export const actions = {
  },

  groupNodes({ commit }, nodeIdArr) {
    console.log('store/data: called action groupNodes with nodes: ' + nodeIdArr)

    let groupNode = {
      name: 'Group of: ',
      type: 'group',
      label: 'Group',
      vis: {
        isUserDefined: true
      label: 'Group'
    }

    commit('AGGREGATE_NODES', {
      nodeIds: nodeIdArr,
      targetGroupNode: groupNode
    })
  },

  aliasNodes({ commit }, nodeIdArr) {
    let groupNode = {
      name: 'Alias of: ',
      type: 'alias',
      label: 'Alias'
    }

    commit('GROUP_NODES', {
    commit('AGGREGATE_NODES', {
      nodeIds: nodeIdArr,
      targetGroupNode: groupNode
    })
  },

  /**
   * @param {object} vuexContext
   * @param {object} payload
   * @param {number} payload.representativeNodeId the "bundle" node
   * @param {number[]} payload.nodeIds to bundle inside the representative node
   */
  bundleNodes({ commit, getters }, payload) {
    console.log('bundleNodes: ' + JSON.stringify(payload))
    commit('UPDATE_NODES', [{
        id: payload.representativeNodeId,
        type: 'bundle'
    }])

    commit('AGGREGATE_NODES', {
      nodeIds: payload.nodeIds,
      targetGroupNode: getters.getNodeById(payload.representativeNodeId)
    })
  },

  ungroupNode({ commit }, nodeId) {
    commit('UNGROUP_NODE', {
    commit('DISAGGREGATE_NODE', {
      nodeId: nodeId
    })
  }
@@ -863,5 +879,9 @@ export const getters = {
  getVisibleLinks: state => filterVisibleLinks(state.links),

  getNodeById: state => id => state.nodes.find(n => n.id === id),
  getLinkById: state => id => state.links.find(n => n.id === id)
  getLinkById: state => id => state.links.find(n => n.id === id),

  replacedNodesCount: (state) => (id) => {
    return state.nodes.filter(n => isReplacedBy(n, id)).length
  }
}
Loading