mirror of
https://github.com/ntop/ntopng.git
synced 2026-05-03 17:30:11 +00:00
689 lines
No EOL
22 KiB
Vue
689 lines
No EOL
22 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" data-bs-toggle="tooltip" data-bs-placement="top" :title="_i18n('date_time_range_picker.btn_zoom_in')"></i>
|
|
</button>
|
|
<button type="button" class="btn zoom-btn" @click="zoomChart(-0.5)">
|
|
<i class="fa-solid fa-magnifying-glass-minus" data-bs-toggle="tooltip" data-bs-placement="top" :title="_i18n('date_time_range_picker.btn_zoom_out')"></i>
|
|
</button>
|
|
<button v-if="showAutoRefresh" button type="button" data-bs-toggle="tooltip" data-bs-placement="top" 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);
|
|
let eventsAttached = false;
|
|
let resizeTimeout = null;
|
|
|
|
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 set_sankey_data();
|
|
});
|
|
|
|
/* ******************************************** */
|
|
|
|
onBeforeUnmount(() => {
|
|
if (svg) {
|
|
// clean up all D3 elements
|
|
svg.selectAll('*').remove();
|
|
svg.on('dblclick', null);
|
|
svg.on('mousemove', null);
|
|
svg.on('mouseleave', null);
|
|
}
|
|
|
|
eventsAttached = false;
|
|
});
|
|
|
|
/* ******************************************** */
|
|
|
|
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;
|
|
}
|
|
|
|
// don't pan if clicking on a draggable node
|
|
if (event.type === 'mousedown' && event.target.closest('.sankey-node, .label')) {
|
|
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();
|
|
}
|
|
|
|
/* ******************************************** */
|
|
// attach resize event listener
|
|
function attach_events() {
|
|
if (eventsAttached) return;
|
|
|
|
const handleResize = () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(async () => {
|
|
await set_sankey_data(true);
|
|
initializeZoom();
|
|
}, 150);
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
eventsAttached = true;
|
|
}
|
|
|
|
/* ******************************************** */
|
|
|
|
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 };
|
|
}
|
|
|
|
|
|
/* ******************************************** */
|
|
function createNodeDrag(links, svg) {
|
|
return d3.drag()
|
|
.on("start", function(event, d) {
|
|
d3.select(this).select("rect").style("fill-opacity", 1.0);
|
|
|
|
// Store initial relative positions ONLY ONCE (on first drag)
|
|
// These offsets should remain constant based on the original layout
|
|
links.forEach(link => {
|
|
if (link.source === d && link.sourceY0Offset === undefined) {
|
|
// Store the relative offset from the top of the source node
|
|
link.sourceY0Offset = link.y0 - d.y0;
|
|
}
|
|
if (link.target === d && link.targetY1Offset === undefined) {
|
|
// Store the relative offset from the top of the target node
|
|
link.targetY1Offset = link.y1 - d.y0;
|
|
}
|
|
});
|
|
})
|
|
.on("drag", function(event, d) {
|
|
const nodeWidth = d.x1 - d.x0;
|
|
const nodeHeight = d.y1 - d.y0;
|
|
|
|
// get current zoom
|
|
const transform = d3.zoomTransform(svg.node());
|
|
const scale = transform.k;
|
|
|
|
// get svg dimensions
|
|
const svgRect = svg.node().getBoundingClientRect();
|
|
const margin = { top: 8, right: 8, bottom: 8, left: 8 };
|
|
|
|
// calculate visible bounds in the sankey coordinate space
|
|
// The sankey extent is [0, 0] to [width - margins, height - margins]
|
|
const sankeyWidth = sankey_size.value.width - margin.left - margin.right;
|
|
const sankeyHeight = sankey_size.value.height - margin.top - margin.bottom;
|
|
|
|
const visibleLeft = Math.max(0, -transform.x / scale);
|
|
const visibleTop = Math.max(0, -transform.y / scale);
|
|
const visibleRight = Math.min(sankeyWidth, visibleLeft + svgRect.width / scale);
|
|
const visibleBottom = Math.min(sankeyHeight, visibleTop + svgRect.height / scale);
|
|
|
|
// do not let the node be dragged out of view
|
|
let constrainedX = Math.max(visibleLeft, Math.min(visibleRight - nodeWidth, event.x));
|
|
let constrainedY = Math.max(visibleTop, Math.min(visibleBottom - nodeHeight, event.y));
|
|
|
|
// update node bounds with constrained positions
|
|
d.x0 = constrainedX;
|
|
d.x1 = constrainedX + nodeWidth;
|
|
d.y0 = constrainedY;
|
|
d.y1 = constrainedY + nodeHeight;
|
|
|
|
// Move the node group
|
|
d3.select(this).attr("transform", `translate(${d.x0}, ${d.y0})`);
|
|
|
|
// update links connected to dragged node, preserving relative positions
|
|
links.forEach(link => {
|
|
if (link.source === d) {
|
|
// Update source link position preserving relative offset
|
|
link.y0 = d.y0 + (link.sourceY0Offset !== undefined ? link.sourceY0Offset : 0);
|
|
}
|
|
if (link.target === d) {
|
|
// Update target link position preserving relative offset
|
|
link.y1 = d.y0 + (link.targetY1Offset !== undefined ? link.targetY1Offset : 0);
|
|
}
|
|
});
|
|
|
|
// redraw all links
|
|
svg.select("g.links").selectAll("path.sankey-link")
|
|
.attr("d", d3.sankeyLinkHorizontal());
|
|
|
|
// update gradients for affected links only
|
|
svg.select("g.links").selectAll("linearGradient")
|
|
.attr("x1", link => link.source.x1)
|
|
.attr("y1", link => link.y0)
|
|
.attr("x2", link => link.target.x0)
|
|
.attr("y2", link => link.y1);
|
|
})
|
|
.on("end", function(event, d) {
|
|
d3.select(this).select("rect").style("fill-opacity", 0.9);
|
|
});
|
|
}
|
|
/* ******************************************** */
|
|
async function draw_sankey() {
|
|
const colors = d3.scaleOrdinal(d3.schemeCategory10);
|
|
let data = props.sankey_data;
|
|
const size = get_size();
|
|
/* Add a margin of 8 px (1 rem) on every side */
|
|
const margin = { top: 8, right: 8, bottom: 8, left: 8 };
|
|
sankey_size.value = size;
|
|
//console.log(`New width: ${sankey_size.value.width} New height: ${sankey_size.value.height}`)
|
|
|
|
svg = d3.select(sankey_wrapper.value)
|
|
.append("svg")
|
|
.attr("height", sankey_size.value.height)
|
|
.attr("width", sankey_size.value.width)
|
|
.append("g")
|
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
|
|
|
sankey = d3.sankey()
|
|
.nodeWidth(15)
|
|
.nodePadding(10)
|
|
.extent([[0, 0], [sankey_size.value.width - margin.right - margin.left, sankey_size.value.height - margin.top - margin.bottom]]);
|
|
|
|
// 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);
|
|
}
|
|
|
|
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")
|
|
|
|
// Helper function to find all nodes in the backward path (left side)
|
|
function findBackwardPath(targetNode, links) {
|
|
const pathNodes = new Set();
|
|
const queue = [targetNode];
|
|
const visited = new Set();
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift();
|
|
if (visited.has(current)) continue;
|
|
visited.add(current);
|
|
pathNodes.add(current);
|
|
|
|
// Find all links where current node is the target (incoming links)
|
|
const incomingLinks = links.filter(link => link.target === current);
|
|
|
|
incomingLinks.forEach(link => {
|
|
const source = link.source;
|
|
// Only nodes to the left
|
|
if (!visited.has(source) && source.x0 < current.x0) {
|
|
queue.push(source);
|
|
}
|
|
});
|
|
}
|
|
|
|
return pathNodes;
|
|
}
|
|
|
|
// Helper function to find all nodes in the forward path (right side)
|
|
function findForwardPath(sourceNode, links) {
|
|
const pathNodes = new Set();
|
|
const queue = [sourceNode];
|
|
const visited = new Set();
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift();
|
|
if (visited.has(current)) continue;
|
|
visited.add(current);
|
|
pathNodes.add(current);
|
|
|
|
// Find all links where current node is the source (outgoing links)
|
|
const outgoingLinks = links.filter(link => link.source === current);
|
|
|
|
outgoingLinks.forEach(link => {
|
|
const target = link.target;
|
|
if (!visited.has(target) && target.x0 > current.x0) { // Only nodes to the right
|
|
queue.push(target);
|
|
}
|
|
});
|
|
}
|
|
|
|
return pathNodes;
|
|
}
|
|
|
|
// Helper function to find all links in the full path
|
|
function findFullPathLinks(pathNodes, links) {
|
|
const pathLinks = new Set();
|
|
|
|
links.forEach(link => {
|
|
if (pathNodes.has(link.source) && pathNodes.has(link.target)) {
|
|
pathLinks.add(link);
|
|
}
|
|
});
|
|
|
|
return pathLinks;
|
|
}
|
|
|
|
// Reset function to restore original styling
|
|
function resetHighlight() {
|
|
svg.selectAll(".sankey-link")
|
|
.style("stroke-opacity", 0.4)
|
|
.style("stroke", (d) => `url(#gradient-${d.index})`);
|
|
|
|
svg.selectAll(".sankey-node")
|
|
.style("fill-opacity", 0.9)
|
|
.style("fill", (d) => colors(d.index % 10)); // Fixed color calculation
|
|
|
|
svg.selectAll(".label")
|
|
.style("fill-opacity", 0.85);
|
|
}
|
|
|
|
// Highlight function for full path
|
|
function highlightFullPath(hoveredLink) {
|
|
const sourceNode = hoveredLink.source;
|
|
const targetNode = hoveredLink.target;
|
|
|
|
// Find all nodes in the backward path (from source to leftmost nodes)
|
|
const backwardNodes = findBackwardPath(sourceNode, links);
|
|
|
|
// Find all nodes in the forward path (from target to rightmost nodes)
|
|
const forwardNodes = findForwardPath(targetNode, links);
|
|
|
|
// Combine all path nodes
|
|
const allPathNodes = new Set([...backwardNodes, ...forwardNodes]);
|
|
|
|
// Find all links in the full path
|
|
const pathLinks = findFullPathLinks(allPathNodes, links);
|
|
|
|
// Dim all links first
|
|
svg.selectAll(".sankey-link")
|
|
.style("stroke-opacity", 0.15);
|
|
|
|
// Highlight path links with moderate opacity
|
|
svg.selectAll(".sankey-link")
|
|
.filter(d => pathLinks.has(d))
|
|
.style("stroke-opacity", 0.7);
|
|
|
|
// Dim all nodes first
|
|
svg.selectAll(".sankey-node")
|
|
.style("fill-opacity", 0.2);
|
|
|
|
// Highlight path nodes with high opacity
|
|
svg.selectAll(".sankey-node")
|
|
.filter(d => allPathNodes.has(d))
|
|
.style("fill-opacity", 1.0);
|
|
|
|
// Dim all labels first
|
|
svg.selectAll(".label")
|
|
.style("fill-opacity", 0.25);
|
|
|
|
// Highlight labels for path nodes
|
|
svg.selectAll(".label")
|
|
.filter(d => allPathNodes.has(d))
|
|
.style("fill-opacity", 1.0);
|
|
}
|
|
|
|
|
|
const d3_nodes = svg.select("g.nodes")
|
|
.selectAll("g")
|
|
.data(nodes)
|
|
.join((enter) => enter.append("g"))
|
|
.attr("transform", (d) => `translate(${d.x0}, ${d.y0})`)
|
|
.call(createNodeDrag(links, svg));
|
|
|
|
d3_nodes.append("rect")
|
|
.attr("height", (d) => Math.max(3, d.y1 - d.y0)) // Ensure minimum height of 3px
|
|
.attr("width", (d) => Math.max(15, d.x1 - d.x0)) // Ensure minimum width of 15px
|
|
.attr("dataIndex", (d) => d.index)
|
|
.attr("fill", (d) => colors(d.index % 10)) // Fixed: use modulo to ensure proper color cycling
|
|
.attr("fill-opacity", 0.9)
|
|
.attr("class", "sankey-node")
|
|
.attr("style", "cursor:move;")
|
|
.style("stroke", "none")
|
|
|
|
|
|
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', 0.85)
|
|
.attr("fill", "#000")
|
|
.attr("x", (d) => (d.x0 < sankey_size.value.width / 2 ? 6 + Math.max(15, d.x1 - d.x0) : -6)) // Adjusted for minimum width
|
|
.attr("y", (d) => Math.max(3, d.y1 - d.y0) / 2) // Adjusted for minimum height
|
|
.attr("alignment-baseline", "middle")
|
|
.attr("text-anchor", (d) => d.x0 < sankey_size.value.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 % 10)); // Fixed color calculation
|
|
|
|
lg_d3.append("stop")
|
|
.attr("offset", "100%")
|
|
.attr("stop-color", (d) => colors(d.target.index % 10)); // Fixed color calculation
|
|
|
|
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("stroke-opacity", 0.4)
|
|
.attr("data-bs-toggle", "tooltip")
|
|
.attr("data-bs-placement", "top")
|
|
.attr("title", (d) => `${d.label}`)
|
|
.text((d) => `${d.label}`)
|
|
.on("mouseover", function (event, d) {
|
|
highlightFullPath(d);
|
|
})
|
|
.on("mouseout", function (event, d) {
|
|
resetHighlight();
|
|
});
|
|
|
|
|
|
svg.on("mouseleave", function () {
|
|
resetHighlight();
|
|
});
|
|
}
|
|
|
|
defineExpose({ draw_sankey, setNoDataFlag });
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.btn-ontop {
|
|
position: absolute;
|
|
right: 0;
|
|
top: -0.7rem;
|
|
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;
|
|
}
|
|
|
|
.sankey-container {
|
|
overflow: hidden;
|
|
}
|
|
</style> |