mirror of
https://github.com/ntop/ntopng.git
synced 2026-05-06 03:45:26 +00:00
439 lines
No EOL
13 KiB
Vue
439 lines
No EOL
13 KiB
Vue
<template>
|
|
<div ref="sankey_div" class="d-flex align-items-center justify-content-center flex-column flex-grow-1 position-relative">
|
|
<!-- Zoom button group -->
|
|
<div v-if="!no_data" class="mb-2">
|
|
<div class="btn-group btn-ontop" role="group">
|
|
<button type="button" class="btn zoom-btn" @click="zoomChart(0.5)">
|
|
<i class="fa-solid fa-magnifying-glass-plus"></i>
|
|
</button>
|
|
<button type="button" class="btn zoom-btn" @click="zoomChart(-0.5)">
|
|
<i class="fa-solid fa-magnifying-glass-minus"></i>
|
|
</button>
|
|
<button v-if="showAutoRefresh" button type="button" class="btn refresh-btn" @click="toggleAutoRefresh"
|
|
:class="{ 'active': autoRefreshEnabled }"
|
|
:title="autoRefreshEnabled ? 'Auto-refresh enabled' : 'Auto-refresh disabled'">
|
|
<i class="fa-solid fa-arrows-rotate" :class="{ 'fa-spin': autoRefreshEnabled }"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- no data -->
|
|
<div v-if="no_data" class="alert alert-info" id="empty-message">
|
|
{{ no_data_message || _i18n('flows_page.no_data') }}
|
|
</div>
|
|
<div ref="sankey_wrapper"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onBeforeMount, onBeforeUnmount, watch, computed } from "vue";
|
|
|
|
const d3 = d3v7;
|
|
const emit = defineEmits(['node_click', 'update_width', 'update_height', 'autorefresh_toggle'])
|
|
const _i18n = (t) => i18n(t);
|
|
|
|
function setNoDataFlag(set_no_data) {
|
|
no_data.value = set_no_data
|
|
}
|
|
|
|
const props = defineProps({
|
|
no_data_message: String,
|
|
width: Number,
|
|
height: Number,
|
|
sankey_data: Object,
|
|
autorefresh: { type: Boolean, default: null }
|
|
});
|
|
|
|
const autoRefreshEnabled = ref(props.autorefresh ?? true);
|
|
const showAutoRefresh = computed(() => props.autorefresh !== null);
|
|
|
|
const sankey_size = ref({});
|
|
const no_data = ref(false);
|
|
const sankey_wrapper = ref(null);
|
|
const sankey_div = ref(null);
|
|
|
|
let svg = null;
|
|
let zoomGroup = null;
|
|
let sankey = null;
|
|
let sankeyData = null;
|
|
let isZoomed = ref(false);
|
|
const currentScale = ref(1); // keep track of zzom
|
|
let nodeDrag = null;
|
|
let isDraggingNode = false;
|
|
|
|
let zoom = null;
|
|
|
|
function toggleAutoRefresh() {
|
|
autoRefreshEnabled.value = !autoRefreshEnabled.value;
|
|
emit('autorefresh_toggle', autoRefreshEnabled.value);
|
|
}
|
|
|
|
/* ******************************************** */
|
|
|
|
onBeforeMount(async () => { });
|
|
|
|
/* ******************************************** */
|
|
|
|
onMounted(async () => {
|
|
await draw_sankey();
|
|
});
|
|
|
|
/* ******************************************** */
|
|
|
|
onBeforeUnmount(() => {
|
|
if (svg) {
|
|
svg.on('dblclick', null);
|
|
svg.on('mousemove', null);
|
|
}
|
|
});
|
|
|
|
/* ******************************************** */
|
|
|
|
watch(() => props.sankey_data, (cur_value, old_value) => {
|
|
set_sankey_data(true);
|
|
});
|
|
|
|
/* ******************************************** */
|
|
|
|
/* ZOOM FUNCTIONS */
|
|
|
|
// zoomValue must be float, 1.0 is 100% zoom, 1.5 is 150% and so on
|
|
function zoomChart(zoomValue) {
|
|
if (!zoom) return;
|
|
if (!svg) return; /* Check if the svg is defined */
|
|
let newZoomValue = Math.max(currentScale.value + zoomValue, 1); // minimum 1x zoom
|
|
currentScale.value = Math.min(newZoomValue, 3); // maximum 3x zoom
|
|
|
|
// get svg center
|
|
const width = sankey_size.value.width;
|
|
const height = sankey_size.value.height;
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
|
|
// compute transformation
|
|
const transform = d3.zoomIdentity
|
|
.translate(centerX, centerY)
|
|
.scale(currentScale.value)
|
|
.translate(-centerX, -centerY);
|
|
|
|
// apply transformation to D3 zoom behavior and zoom group
|
|
svg.transition()
|
|
.duration(300)
|
|
.call(zoom.transform, transform);
|
|
|
|
zoomGroup.transition()
|
|
.duration(300)
|
|
.attr("transform", `translate(${transform.x}, ${transform.y}) scale(${transform.k})`);
|
|
|
|
isZoomed.value = currentScale.value > 1;
|
|
}
|
|
|
|
/* ******************************************** */
|
|
function initializeZoom() {
|
|
if (!svg) return;
|
|
zoomGroup = svg.select('.zoom-group');
|
|
zoom = d3.zoom()
|
|
.scaleExtent([1, 5]) // Max 500% zoom
|
|
.on("zoom", (event) => {
|
|
currentScale.value = event.transform.k;
|
|
isZoomed.value = currentScale.value > 1;
|
|
zoomGroup.attr("transform", event.transform);
|
|
})
|
|
.filter(event => {
|
|
if (event.type === 'dblclick') {
|
|
event.preventDefault();
|
|
return false;
|
|
}
|
|
if (event.type === 'mousedown' && event.button !== 0) {
|
|
return false;
|
|
}
|
|
|
|
// disable panning if not zoomed
|
|
if (event.type === 'mousedown' && currentScale.value <= 1) {
|
|
return false;
|
|
}
|
|
|
|
return !event.ctrlKey && event.type !== 'dblclick';
|
|
});
|
|
|
|
// update cursor style based on zoom
|
|
svg.on('mousedown', (event) => {
|
|
|
|
if (event.button === 0 && currentScale.value > 1) { // left click and zoomed
|
|
svg.style("cursor", `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512' width='18px' height='18px'%3E%3Cpath d='M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 96-96 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6l0-32 96 0 0 96-32 0c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0 0-96 96 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9s-19.8 16.6-19.8 29.6l0 32-96 0 0-96 32 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64z'/%3E%3C/svg%3E"), move`);
|
|
} else {
|
|
svg.style("cursor", "default");
|
|
}
|
|
});
|
|
|
|
// reset cursor on mouse up
|
|
svg.on('mouseup', () => {
|
|
if (currentScale.value > 1) {
|
|
svg.style("cursor", "grab");
|
|
} else {
|
|
svg.style("cursor", "default");
|
|
}
|
|
});
|
|
|
|
// add double click handler to reset zoom
|
|
svg.on('dblclick', (event) => {
|
|
event.preventDefault();
|
|
resetZoom();
|
|
});
|
|
|
|
// init zoom
|
|
svg.call(zoom)
|
|
.call(zoom.transform, d3.zoomIdentity);
|
|
}
|
|
|
|
/* ******************************************** */
|
|
|
|
function resetZoom() {
|
|
if (!zoom) return;
|
|
if (!svg) return; /* Check if the svg is created or not */
|
|
svg.transition()
|
|
.duration(750)
|
|
.call(zoom.transform, d3.zoomIdentity);
|
|
|
|
currentScale.value = 1;
|
|
isZoomed.value = false;
|
|
|
|
zoomGroup.attr("transform", "translate(0,0) scale(1)");
|
|
}
|
|
|
|
/* ******************************************** */
|
|
|
|
async function set_sankey_data(reset) {
|
|
if (reset && svg) {
|
|
sankey_wrapper.value.replaceChildren();
|
|
}
|
|
if (props.sankey_data.nodes == null || props.sankey_data.links == null
|
|
|| props.sankey_data.length == 0 || props.sankey_data.links.length == 0) {
|
|
setNoDataFlag(true); /* No data */
|
|
return;
|
|
}
|
|
|
|
setNoDataFlag(false) /* There is some data */
|
|
await draw_sankey();
|
|
attach_events();
|
|
initializeZoom();
|
|
}
|
|
|
|
/* ******************************************** */
|
|
|
|
function attach_events() {
|
|
window.addEventListener('resize', async () => {
|
|
set_sankey_data(true);
|
|
initializeZoom();
|
|
});
|
|
}
|
|
|
|
/* ******************************************** */
|
|
|
|
function get_size() {
|
|
emit('update_width');
|
|
let width = props.width
|
|
if (width == undefined) { width = $(sankey_div.value).width() }
|
|
|
|
emit('update_height');
|
|
let height = props.height
|
|
if (height == undefined) { height = $(sankey_div.value).height() }
|
|
|
|
return { width, height };
|
|
}
|
|
|
|
|
|
/* ******************************************** */
|
|
|
|
async function draw_sankey() {
|
|
const colors = d3.scaleOrdinal(d3.schemeCategory10);
|
|
let data = props.sankey_data;
|
|
const size = get_size();
|
|
sankey_size.value = size;
|
|
|
|
sankey = d3.sankey()
|
|
.nodeWidth(15)
|
|
.nodePadding(10)
|
|
.extent([[0, 0], [size.width, size.height * 0.95]]);
|
|
|
|
// Sort nodes descending by value property
|
|
sankey.nodeSort((a, b) => d3.descending(a.value, b.value));
|
|
|
|
let links, nodes;
|
|
if (Object.keys(data).length !== 0) {
|
|
sankeyData = sankey(data);
|
|
({ links, nodes } = sankeyData);
|
|
}
|
|
|
|
svg = d3.select(sankey_wrapper.value)
|
|
.append("svg")
|
|
.attr("height", size.height * 1.2)
|
|
.attr("width", size.width);
|
|
|
|
if (!links || !nodes) return;
|
|
|
|
svg.style("cursor", "default");
|
|
|
|
const zoomGroup = svg.append("g")
|
|
.attr("class", "zoom-group");
|
|
|
|
zoomGroup.append("g")
|
|
.attr("class", "links")
|
|
.style("stroke", "#000")
|
|
.style("stroke-opacity", 0.3)
|
|
.style("fill", "none");
|
|
|
|
zoomGroup.append("g")
|
|
.attr("class", "nodes")
|
|
.style("stroke", "#000")
|
|
.style("stroke-opacity", 0.5);
|
|
|
|
const d3_nodes = svg.select("g.nodes")
|
|
.selectAll("g")
|
|
.data(nodes)
|
|
.join((enter) => enter.append("g"))
|
|
.attr("transform", (d) => `translate(${d.x0}, ${d.y0})`);
|
|
|
|
d3_nodes.append("rect")
|
|
.attr("height", (d) => d.y1 - d.y0)
|
|
.attr("width", (d) => d.x1 - d.x0)
|
|
.attr("dataIndex", (d) => d.index)
|
|
.attr("fill", (d) => colors(d.index / nodes.length))
|
|
.attr("class", "sankey-node")
|
|
.attr("style", "cursor:move;");
|
|
|
|
d3.selectAll("rect").append("title").text((d) => `${d?.label}`);
|
|
|
|
d3_nodes.append("text")
|
|
.attr('class', 'label')
|
|
.style('pointer-events', 'auto')
|
|
.attr("style", "cursor:pointer;")
|
|
.style('fill-opacity', 1)
|
|
.attr("fill", "#000")
|
|
.attr("x", (d) => (d.x0 < size.width / 2 ? 6 + (d.x1 - d.x0) : -6))
|
|
.attr("y", (d) => (d.y1 - d.y0) / 2)
|
|
.attr("alignment-baseline", "middle")
|
|
.attr("text-anchor", (d) => d.x0 < size.width / 2 ? "start" : "end")
|
|
.attr("font-size", 12)
|
|
.text((d) => d.label)
|
|
.on("click", function (event, data_obj) {
|
|
event.stopPropagation();
|
|
// emit node click event to parent component
|
|
emit('node_click', data_obj.data, data_obj);
|
|
});
|
|
|
|
// Draw links
|
|
const links_d3 = svg.select("g.links")
|
|
.selectAll("g")
|
|
.data(links)
|
|
.join((enter) => enter.append("g"));
|
|
|
|
const lg_d3 = links_d3.append("linearGradient");
|
|
lg_d3.attr("id", (d) => `gradient-${d.index}`)
|
|
.attr("gradientUnits", "userSpaceOnUse")
|
|
.attr("x1", (d) => d.source.x1)
|
|
.attr("x2", (d) => d.target.x0);
|
|
|
|
lg_d3.append("stop")
|
|
.attr("offset", "0")
|
|
.attr("stop-color", (d) => colors(d.source.index / nodes.length));
|
|
|
|
lg_d3.append("stop")
|
|
.attr("offset", "100%")
|
|
.attr("stop-color", (d) => colors(d.target.index / nodes.length));
|
|
|
|
links_d3
|
|
.append("path")
|
|
.attr("class", "sankey-link")
|
|
.attr("d", d3.sankeyLinkHorizontal())
|
|
.attr("stroke-width", (d) => Math.max(1, d.width))
|
|
.attr("stroke", (d) => `url(#gradient-${d.index}`)
|
|
.attr("data-bs-toggle", "tooltip")
|
|
.attr("data-bs-placement", "top")
|
|
.attr("title", (d) => `${d.label}`)
|
|
.text((d) => `${d.label}`);
|
|
}
|
|
|
|
defineExpose({ draw_sankey, setNoDataFlag });
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.btn-ontop {
|
|
position: absolute;
|
|
right: 0;
|
|
top: -20px;
|
|
z-index: 10;
|
|
}
|
|
|
|
.alert {
|
|
margin: 20px;
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.alert-info {
|
|
background-color: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
color: #0c5460;
|
|
}
|
|
|
|
.zoom-btn {
|
|
background-color: #fd7e14 !important;
|
|
color: white !important;
|
|
border: none !important;
|
|
height: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.zoom-btn:hover {
|
|
background-color: #e76b06 !important;
|
|
}
|
|
|
|
.sankey-wrapper-container {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.btn-group-container {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.zoom-controls {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
z-index: 10;
|
|
}
|
|
|
|
.refresh-btn {
|
|
background-color: #6c757d !important;
|
|
color: white !important;
|
|
border: none !important;
|
|
height: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.refresh-btn:hover {
|
|
background-color: #5a6268 !important;
|
|
}
|
|
|
|
.refresh-btn.active {
|
|
background-color: #007bff !important;
|
|
}
|
|
|
|
.refresh-btn.active:hover {
|
|
background-color: #0056b3 !important;
|
|
}
|
|
|
|
.sankey-container svg {
|
|
overflow: visible;
|
|
}
|
|
</style> |