dash14 / v-network-graph

An interactive network graph visualization component for Vue 3
https://dash14.github.io/v-network-graph/
MIT License
485 stars 44 forks source link

3d force pushes nodes out of view #91

Closed bureauvanlieshout closed 1 year ago

bureauvanlieshout commented 1 year ago

Hi dash14, I am using your fantastic v-network-graph with d3-force for positioning of nodes. I notice that sometimes nodes are pushed out of view. We use a fixed height (500px) for the graph and the width is flexible. Nodes disappear off the edge on the top and the bottom. I also see this happening in the docs example for Positioning nodes with d3 force. Would be great if nodes and their labels (our nodes have different sizes and labels at the bottom) would remain in view. Played around with many settings, but a ‘hard’ boundary or some form of auto-zoom to keep all items in view would be great. Thanks for the great work! Bart

dash14 commented 1 year ago

Hi @bureauvanlieshout, Thanks for using v-network-graph! Currently there is no feature that will fulfill your request, but I will give you what I can think of as a workaround. The following is an example that periodically checks the contents and adjusts the display to include all objects. It is based on the "Position nodes with d3-force" in the docs, but the view.scalingObjects config is set to true for easy recognition of zoom changes.

<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
import * as vNG from "v-network-graph";
import { ForceEdgeDatum, ForceLayout, ForceNodeDatum } from "v-network-graph/lib/force-layout";

const nodeCount = ref(20);
const nodes = reactive({});
const edges = reactive({});
const layouts = ref<vNG.Layouts>({ nodes: {} });

// initialize network
buildNetwork(nodeCount.value, nodes, edges);

watch(nodeCount, () => {
  buildNetwork(nodeCount.value, nodes, edges);
});

const configs = reactive(
  vNG.defineConfigs({
    view: {
      scalingObjects: true,
      layoutHandler: new ForceLayout({
        positionFixedByDrag: false,
        positionFixedByClickWithAltKey: true,
        createSimulation: (d3, nodes, edges) => {
          const forceLink = d3.forceLink<ForceNodeDatum, ForceEdgeDatum>(edges).id((d: Node) => d.id)
          return d3
            .forceSimulation(nodes)
            .force("edge", forceLink.distance(30))
            .force("charge", d3.forceManyBody())
            .force("collide", d3.forceCollide(30).strength(0.2))
            .force("center", d3.forceCenter().strength(0.05))
            .alphaMin(0.001)
        }
      }),
    },
    node: {
      label: {
        visible: true,
        text: "label",
      },
    },
  })
);

function buildNetwork(count: number, nodes: vNG.Nodes, edges: vNG.Edges) {
  const idNums = [...Array(count)].map((_, i) => i);

  // nodes
  const newNodes = Object.fromEntries(
    idNums.map((id) => [`node${id}`, { label: `${id}` }])
  );
  Object.keys(nodes).forEach((id) => delete nodes[id]);
  Object.assign(nodes, newNodes);

  // edges
  const makeEdgeEntry = (id1: number, id2: number) => {
    return [
      `edge${id1}-${id2}`,
      { source: `node${id1}`, target: `node${id2}` },
    ];
  };
  const newEdges = Object.fromEntries([
    ...idNums
      .map((n) => [n, (Math.floor(n / 5) * 5) % count])
      .map(([n, m]) => (n === m ? [n, (n + 5) % count] : [n, m]))
      .map(([n, m]) => makeEdgeEntry(n, m)),
  ]);
  Object.keys(edges).forEach((id) => delete edges[id]);
  Object.assign(edges, newEdges);
}

const graph = ref<vNG.VNetworkGraphInstance>();
const svg = ref<SVGSVGElement>();
const viewport = ref<SVGGElement>();

function adjustToDisplayTheWhole() {
  if (!graph.value) return;
  if (!svg.value || !viewport.value) return;
  const outerRect = svg.value.getBoundingClientRect();
  const innerRect = viewport.value.getBoundingClientRect();
  if (
    innerRect.x < outerRect.x ||
    outerRect.right < innerRect.right ||
    innerRect.y < outerRect.y ||
    outerRect.bottom < innerRect.bottom
  ) {
    if (
      outerRect.width < innerRect.width ||
      outerRect.height < innerRect.height
    ) {
      // zoom and centering
      graph.value.fitToContents();
    } else {
      // only panning.
      // If you want to also zoom in to fit the size of the topology,
      // use `fitToContents()` here too.
      graph.value.panToCenter();
    }
  }
}

let timerId: ReturnType<typeof setInterval>;
onMounted(() => {
  if (graph.value) {
    svg.value = graph.value.$el.querySelector("svg");
    viewport.value = graph.value.$el.querySelector(".svg-pan-zoom_viewport");
  }
  timerId = setInterval(adjustToDisplayTheWhole, 1000);
});

onUnmounted(() => {
  clearInterval(timerId);
});
</script>

<template>
  <div>
    <label>Node count:</label>
    <input v-model="nodeCount" type="number" :min="3" :max="200" />
  </div>

  <v-network-graph
    class="graph"
    ref="graph"
    :nodes="nodes"
    :edges="edges"
    :layouts="layouts"
    :configs="configs"
  />
</template>

<style>
.graph {
  height: 500px;
  border: 1px solid #000;
}
</style>

https://user-images.githubusercontent.com/24878247/197347059-05edb4ad-430a-4e8d-aa55-e9313aefb29d.mov

If you want to exclude adjustment when zoomed in by user operation, you might not make it a periodic process, but rather execute the adjustment process a few seconds after the number of nodes has changed.

I hope this information helps you. BTW, since this workaround requires touching the internal DOM, I would consider implementing and providing it as a feature in the future. Thanks for the suggestion!

dash14 commented 1 year ago

I close this issue for now. If you have any other question/comment, please reopen this issue.