Files
MeshChatX/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
T
2026-01-01 15:05:29 -06:00

813 lines
34 KiB
Vue

<template>
<div class="flex-1 h-full min-w-0 relative dark:bg-zinc-950 overflow-hidden">
<!-- network -->
<div id="network" class="w-full h-full"></div>
<!-- loading overlay -->
<div
v-if="isLoading"
class="absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/10 backdrop-blur-[2px] transition-all duration-300"
>
<div
class="bg-white/90 dark:bg-zinc-900/90 border border-gray-200 dark:border-zinc-800 rounded-2xl px-6 py-4 flex flex-col items-center gap-3"
>
<div class="relative">
<div
class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
></div>
<div class="absolute inset-0 flex items-center justify-center">
<div
class="w-6 h-6 border-4 border-emerald-500/20 border-b-emerald-500 rounded-full animate-spin-reverse"
></div>
</div>
</div>
<div class="text-sm font-medium text-gray-900 dark:text-zinc-100">{{ loadingStatus }}</div>
<div
v-if="totalNodesToLoad > 0"
class="w-48 h-1.5 bg-gray-200 dark:bg-zinc-800 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 transition-all duration-300"
:style="{ width: `${(loadedNodesCount / totalNodesToLoad) * 100}%` }"
></div>
</div>
</div>
</div>
<!-- controls & search -->
<div
class="absolute top-2 left-2 right-2 sm:top-4 sm:left-4 sm:right-4 z-10 flex flex-col sm:flex-row gap-2 pointer-events-none"
>
<!-- header glass card -->
<div
class="pointer-events-auto border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl rounded-2xl overflow-hidden w-full sm:min-w-[280px] sm:w-auto transition-all duration-300"
>
<div
class="flex items-center px-4 sm:px-5 py-3 sm:py-4 cursor-pointer hover:bg-gray-50/50 dark:hover:bg-zinc-800/50 transition-colors"
@click="isShowingControls = !isShowingControls"
>
<div class="flex-1 flex flex-col min-w-0 mr-2">
<span class="font-bold text-gray-900 dark:text-zinc-100 tracking-tight truncate"
>Reticulum Mesh</span
>
<span
class="text-[10px] uppercase font-bold text-gray-500 dark:text-zinc-500 tracking-widest truncate"
>Network Visualizer</span
>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95"
:disabled="isUpdating || isLoading"
@click.stop="manualUpdate"
>
<svg
v-if="!isUpdating && !isLoading"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4 sm:w-5 sm:h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<svg
v-else
class="animate-spin h-4 w-4 sm:h-5 sm:w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<div class="w-5 sm:w-6 flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 transition-transform duration-300"
:class="{ 'rotate-180': isShowingControls }"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
<div
v-show="isShowingControls"
class="px-5 pb-5 space-y-4 animate-in fade-in slide-in-from-top-2 duration-300"
>
<!-- divider -->
<div
class="h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-zinc-800 to-transparent"
></div>
<!-- auto update toggle -->
<div class="flex items-center justify-between">
<label
for="auto-reload"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>Auto Update</label
>
<Toggle id="auto-reload" v-model="autoReload" />
</div>
<!-- physics toggle -->
<div class="flex items-center justify-between">
<label
for="enable-physics"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>Live Layout</label
>
<Toggle id="enable-physics" v-model="enablePhysics" />
</div>
<!-- stats -->
<div class="grid grid-cols-2 gap-3 pt-2">
<div
class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
>
Nodes
</div>
<div class="text-lg font-bold text-blue-600 dark:text-blue-400">{{ nodes.length }}</div>
</div>
<div
class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
>
Links
</div>
<div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">
{{ edges.length }}
</div>
</div>
</div>
<div
class="bg-zinc-950/5 dark:bg-white/5 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-2"
>
Interfaces
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
>{{ onlineInterfaces.length }} Online</span
>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-red-500"></div>
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
>{{ offlineInterfaces.length }} Offline</span
>
</div>
</div>
</div>
</div>
</div>
<!-- search box -->
<div class="sm:ml-auto w-full sm:w-auto pointer-events-auto">
<div class="relative group">
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path
fill-rule="evenodd"
d="M9 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM2.25 10a7.75 7.75 0 1 1 14.03 4.5l3.47 3.47a.75.75 0 0 1-1.06 1.06l-3.47-3.47A7.75 7.75 0 0 1 2.25 10Z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="`Search nodes (${nodes.length})...`"
class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 transition-all dark:text-zinc-100 shadow-sm"
/>
<button
v-if="searchQuery"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-zinc-200 transition-colors"
@click="searchQuery = ''"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- navigation breadcrumb style legend -->
<div
class="absolute bottom-4 right-4 z-10 hidden sm:flex items-center gap-2 px-4 py-2 rounded-full border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl"
>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-full border-2 border-emerald-500 bg-emerald-500/20"></div>
<span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Direct</span>
</div>
<div class="w-px h-3 bg-gray-200 dark:bg-zinc-800 mx-1"></div>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-full border-2 border-blue-500/50 bg-blue-500/10"></div>
<span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Multi-Hop</span>
</div>
</div>
</div>
</template>
<script>
import "vis-network/styles/vis-network.css";
import { Network } from "vis-network";
import { DataSet } from "vis-data";
import * as mdi from "@mdi/js";
import Utils from "../../js/Utils";
import Toggle from "../forms/Toggle.vue";
export default {
name: "NetworkVisualiser",
components: {
Toggle,
},
data() {
return {
config: null,
autoReload: false,
reloadInterval: null,
isShowingControls: true,
isUpdating: false,
isLoading: false,
enablePhysics: true,
loadingStatus: "Initializing...",
loadedNodesCount: 0,
totalNodesToLoad: 0,
interfaces: [],
pathTable: [],
announces: {},
conversations: {},
network: null,
nodes: new DataSet(),
edges: new DataSet(),
iconCache: {},
pageSize: 100,
searchQuery: "",
};
},
computed: {
onlineInterfaces() {
return this.interfaces.filter((i) => i.status);
},
offlineInterfaces() {
return this.interfaces.filter((i) => !i.status);
},
},
watch: {
enablePhysics(val) {
if (this.network) {
this.network.setOptions({ physics: { enabled: val } });
}
},
searchQuery() {
// we don't want to trigger a full update from server, just re-run the filtering on existing data
this.processVisualization();
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
if (this.network) {
this.network.destroy();
}
},
mounted() {
const isMobile = window.innerWidth < 640;
if (isMobile) {
this.isShowingControls = false;
}
this.init();
},
methods: {
async getInterfaceStats() {
try {
const response = await window.axios.get(`/api/v1/interface-stats`);
this.interfaces = response.data.interface_stats?.interfaces ?? [];
} catch (e) {
console.error("Failed to fetch interface stats", e);
}
},
async getPathTableBatch() {
this.pathTable = [];
let offset = 0;
let totalCount = 1; // dummy initial value
while (offset < totalCount) {
this.loadingStatus = `Loading Paths (${offset} / ${totalCount === 1 ? "..." : totalCount})`;
try {
const response = await window.axios.get(`/api/v1/path-table`, {
params: { limit: this.pageSize, offset: offset },
});
this.pathTable.push(...response.data.path_table);
totalCount = response.data.total_count;
offset += this.pageSize;
} catch (e) {
console.error("Failed to fetch path table batch", e);
break;
}
}
},
async getAnnouncesBatch() {
this.announces = {};
let offset = 0;
let totalCount = 1;
while (offset < totalCount) {
this.loadingStatus = `Loading Announces (${offset} / ${totalCount === 1 ? "..." : totalCount})`;
try {
const response = await window.axios.get(`/api/v1/announces`, {
params: { limit: this.pageSize, offset: offset },
});
for (const announce of response.data.announces) {
this.announces[announce.destination_hash] = announce;
}
totalCount = response.data.total_count;
offset += this.pageSize;
} catch (e) {
console.error("Failed to fetch announces batch", e);
break;
}
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
console.error("Failed to fetch config", e);
}
},
async getConversations() {
try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
this.conversations = {};
for (const conversation of response.data.conversations) {
this.conversations[conversation.destination_hash] = conversation;
}
} catch (e) {
console.error("Failed to fetch conversations", e);
}
},
async createIconImage(iconName, foregroundColor, backgroundColor, size = 64) {
const cacheKey = `${iconName}-${foregroundColor}-${backgroundColor}-${size}`;
if (this.iconCache[cacheKey]) {
return this.iconCache[cacheKey];
}
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
// draw background circle
const gradient = ctx.createLinearGradient(0, 0, 0, size);
gradient.addColorStop(0, backgroundColor);
// slightly darken the bottom for depth
gradient.addColorStop(1, backgroundColor);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 2, 0, 2 * Math.PI);
ctx.fill();
// stroke
ctx.strokeStyle = "rgba(255,255,255,0.1)";
ctx.lineWidth = 2;
ctx.stroke();
// load MDI icon SVG
const iconSvg = this.getMdiIconSvg(iconName, foregroundColor);
const img = new Image();
const svgBlob = new Blob([iconSvg], { type: "image/svg+xml" });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
ctx.drawImage(img, size * 0.25, size * 0.25, size * 0.5, size * 0.5);
URL.revokeObjectURL(url);
const dataUrl = canvas.toDataURL();
this.iconCache[cacheKey] = dataUrl;
resolve(dataUrl);
};
img.onerror = () => {
URL.revokeObjectURL(url);
const dataUrl = canvas.toDataURL();
this.iconCache[cacheKey] = dataUrl;
resolve(dataUrl);
};
img.src = url;
});
},
getMdiIconSvg(iconName, foregroundColor) {
const mdiIconName =
"mdi" +
iconName
.split("-")
.map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join("");
const iconPath = mdi[mdiIconName] || mdi["mdiAccountOutline"];
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
},
async init() {
const container = document.getElementById("network");
const isDarkMode = document.documentElement.classList.contains("dark");
this.network = new Network(
container,
{
nodes: this.nodes,
edges: this.edges,
},
{
interaction: {
tooltipDelay: 100,
hover: true,
hideEdgesOnDrag: true,
hideEdgesOnZoom: true,
},
layout: {
randomSeed: 42,
improvedLayout: false, // faster for large networks
},
physics: {
enabled: this.enablePhysics,
solver: "barnesHut",
barnesHut: {
gravitationalConstant: -8000,
springConstant: 0.04,
springLength: 150,
damping: 0.09,
avoidOverlap: 0.5,
},
stabilization: {
enabled: true,
iterations: 100,
updateInterval: 25,
},
},
nodes: {
borderWidth: 2,
borderWidthSelected: 4,
font: {
face: "Inter, system-ui, sans-serif",
strokeWidth: 4,
strokeColor: isDarkMode ? "rgba(9, 9, 11, 0.95)" : "rgba(255, 255, 255, 0.95)",
},
shadow: {
enabled: false,
},
},
edges: {
smooth: {
type: "continuous",
roundness: 0.5,
},
selectionWidth: 4,
hoverWidth: 3,
},
}
);
this.network.on("doubleClick", (params) => {
const clickedNodeId = params.nodes[0];
if (!clickedNodeId) return;
const node = this.nodes.get(clickedNodeId);
if (!node || !node._announce) return;
const announce = node._announce;
if (announce.aspect === "lxmf.delivery") {
this.$router.push({
name: "messages",
params: { destinationHash: announce.destination_hash },
});
} else if (announce.aspect === "nomadnetwork.node") {
this.$router.push({
name: "nomadnetwork",
params: { destinationHash: announce.destination_hash },
});
}
});
await this.manualUpdate();
// auto reload
this.reloadInterval = setInterval(this.onAutoReload, 15000);
},
async manualUpdate() {
if (this.isLoading) return;
this.isLoading = true;
this.isUpdating = true;
try {
await this.update();
} finally {
this.isLoading = false;
this.isUpdating = false;
}
},
async onAutoReload() {
if (!this.autoReload || this.isUpdating || this.isLoading) return;
this.isUpdating = true;
try {
await this.update();
} finally {
this.isUpdating = false;
}
},
async update() {
this.loadingStatus = "Fetching basic info...";
await this.getConfig();
await this.getInterfaceStats();
await this.getConversations();
this.loadingStatus = "Fetching network table...";
await this.getPathTableBatch();
this.loadingStatus = "Fetching node data...";
await this.getAnnouncesBatch();
await this.processVisualization();
},
async processVisualization() {
this.loadingStatus = "Processing visualization...";
const newNodes = [];
const newEdges = [];
const isDarkMode = document.documentElement.classList.contains("dark");
const fontColor = isDarkMode ? "#ffffff" : "#000000";
// search filter helper
const searchLower = this.searchQuery.toLowerCase();
const matchesSearch = (text) => !this.searchQuery || (text && text.toLowerCase().includes(searchLower));
// Add me
const meLabel = this.config?.display_name ?? "Local Node";
if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) {
newNodes.push({
id: "me",
group: "me",
size: 50,
shape: "circularImage",
image: "/assets/images/reticulum_logo_512.png",
label: meLabel,
title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`,
color: { border: "#3b82f6", background: isDarkMode ? "#1e3a8a" : "#dbeafe" },
font: { color: fontColor, size: 16, bold: true },
});
}
// Add interfaces
for (const entry of this.interfaces) {
let label = entry.interface_name ?? entry.name;
if (entry.type === "LocalServerInterface" || entry.parent_interface_name != null) {
label = entry.name;
}
if (matchesSearch(label) || matchesSearch(entry.name)) {
newNodes.push({
id: entry.name,
group: "interface",
label: label,
title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
size: 35,
shape: "circularImage",
image: entry.status
? "/assets/images/network-visualiser/interface_connected.png"
: "/assets/images/network-visualiser/interface_disconnected.png",
color: { border: entry.status ? "#10b981" : "#ef4444" },
font: { color: fontColor, size: 12 },
});
newEdges.push({
id: `me~${entry.name}`,
from: "me",
to: entry.name,
color: entry.status ? (isDarkMode ? "#065f46" : "#10b981") : isDarkMode ? "#7f1d1d" : "#ef4444",
width: 3,
length: 200,
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
});
}
}
// Process path table in batches to prevent UI block
this.totalNodesToLoad = this.pathTable.length;
this.loadedNodesCount = 0;
const aspectsToShow = ["lxmf.delivery", "nomadnetwork.node"];
// Process in chunks of 50
const chunkSize = 50;
for (let i = 0; i < this.pathTable.length; i += chunkSize) {
const chunk = this.pathTable.slice(i, i + chunkSize);
for (const entry of chunk) {
this.loadedNodesCount++;
if (entry.hops == null) continue;
const announce = this.announces[entry.hash];
if (!announce || !aspectsToShow.includes(announce.aspect)) continue;
const displayName = announce.custom_display_name ?? announce.display_name;
if (
!matchesSearch(displayName) &&
!matchesSearch(announce.destination_hash) &&
!matchesSearch(announce.identity_hash)
) {
continue;
}
const conversation = this.conversations[announce.destination_hash];
const node = {
id: entry.hash,
group: "announce",
size: 25,
_announce: announce,
font: { color: fontColor, size: 11 },
};
node.label = displayName;
node.title = `${displayName}\nAspect: ${announce.aspect}\nHops: ${entry.hops}\nVia: ${entry.interface}\nLast Seen: ${Utils.convertDateTimeToLocalDateTimeString(new Date(announce.updated_at))}`;
if (announce.aspect === "lxmf.delivery") {
if (conversation?.lxmf_user_icon) {
node.shape = "circularImage";
node.image = await this.createIconImage(
conversation.lxmf_user_icon.icon_name,
conversation.lxmf_user_icon.foreground_colour,
conversation.lxmf_user_icon.background_colour,
64
);
node.size = 30;
} else {
node.shape = "circularImage";
node.image =
entry.hops === 1
? "/assets/images/network-visualiser/user_1hop.png"
: "/assets/images/network-visualiser/user.png";
}
node.color = { border: entry.hops === 1 ? "#10b981" : "#3b82f6" };
} else if (announce.aspect === "nomadnetwork.node") {
node.shape = "circularImage";
node.image =
entry.hops === 1
? "/assets/images/network-visualiser/server_1hop.png"
: "/assets/images/network-visualiser/server.png";
node.color = { border: entry.hops === 1 ? "#10b981" : "#8b5cf6" };
}
newNodes.push(node);
newEdges.push({
id: `${entry.interface}~${entry.hash}`,
from: entry.interface,
to: entry.hash,
color:
entry.hops === 1
? isDarkMode
? "#065f46"
: "#10b981"
: isDarkMode
? "#1e3a8a"
: "#3b82f6",
width: entry.hops === 1 ? 2 : 1,
dashes: entry.hops > 1,
opacity: entry.hops === 1 ? 1 : 0.5,
});
}
// Allow UI to breathe
if (i % 100 === 0) {
this.loadingStatus = `Processing Visualization (${this.loadedNodesCount} / ${this.totalNodesToLoad})...`;
await new Promise((r) => setTimeout(r, 0));
}
}
this.processNewNodes(newNodes);
this.processNewEdges(newEdges);
this.totalNodesToLoad = 0;
this.loadedNodesCount = 0;
},
processNewNodes(newNodes) {
const oldNodeIds = this.nodes.getIds();
const newNodeIds = newNodes.map((n) => n.id);
const newNodeIdsSet = new Set(newNodeIds);
// remove old
const toRemove = oldNodeIds.filter((id) => !newNodeIdsSet.has(id));
if (toRemove.length > 0) this.nodes.remove(toRemove);
// update/add
this.nodes.update(newNodes);
},
processNewEdges(newEdges) {
const oldEdgeIds = this.edges.getIds();
const newEdgeIds = newEdges.map((e) => e.id);
const newEdgeIdsSet = new Set(newEdgeIds);
// remove old
const toRemove = oldEdgeIds.filter((id) => !newEdgeIdsSet.has(id));
if (toRemove.length > 0) this.edges.remove(toRemove);
// update/add
this.edges.update(newEdges);
},
},
};
</script>
<style>
.vis-network:focus {
outline: none;
}
.vis-tooltip {
color: #f4f4f5 !important;
background: rgba(9, 9, 11, 0.9) !important;
border: 1px solid rgba(63, 63, 70, 0.5) !important;
border-radius: 12px !important;
padding: 12px 16px !important;
font-size: 13px !important;
font-weight: 500 !important;
font-style: normal !important;
font-family: Inter, system-ui, sans-serif !important;
line-height: 1.5 !important;
backdrop-filter: blur(8px) !important;
pointer-events: none !important;
}
#network {
background-color: #f8fafc;
background-image: radial-gradient(#e2e8f0 1px, transparent 1px);
background-size: 32px 32px;
}
.dark #network {
background-color: #09090b;
background-image: radial-gradient(#18181b 1px, transparent 1px);
background-size: 32px 32px;
}
@keyframes spin-reverse {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
.animate-spin-reverse {
animation: spin-reverse 1s linear infinite;
}
</style>