ntopng/http_src/vue/pie-test.vue

448 lines
13 KiB
Vue

<template>
<div class="d-flex flex-column flex-grow-1 position-relative" ref="chartParent">
<div v-if="chart_data_available" class="d3-chart-container" ref="chartContainer">
<h3 v-if="i18n_title">{{ _i18n(i18n_title) }}</h3>
</div>
<div v-else style="position: relative; display: flex; flex-direction: column; justify-content: center; align-items: center; width: 100%; height: 100%; min-height: 250px; padding: 5% 20px; color: #666;">
<div style="font-size: clamp(16px, 2vw, 18px); margin-bottom: 2vh;"><i class="fas fa-search"></i> {{_i18n("as_overview.no_asn_available")}}</div>
<div style="font-size: clamp(14px, 1.5vw, 16px);"> {{ _i18n("as_overview.no_data") }} </div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed, nextTick } from "vue";
const d3 = d3v7;
import dataUtils from "../utilities/data-utils";
const _i18n = (t) => i18n(t);
const chart_data_available = ref(true);
const chartContainer = ref(null);
const chartParent = ref(null);
const url_list = ref(null);
const chartData = ref(null);
let resizeObserver = null;
const maxPieSectors = 5;
const props = defineProps({
i18n_title: String,
max_width: Number,
max_height: Number,
pie_data: Object,
no_data_message: String,
filters: Object,
});
function drawChart(data) {
const container = chartContainer.value;
// Check that container exists before proceeding
if (!container) {
chart_data_available.value = false;
return;
}
if (!data || !data.series || !data.labels || data.series.length === 0) {
chart_data_available.value = false;
return;
}
// Clear previous content
container.innerHTML = '';
// get container dimension
const containerWidth = Math.max(container.offsetWidth || 300, 300);
const containerHeight = Math.max(container.offsetHeight || 300, 300);
// make space for title, chart and legend
const titleHeight = container.querySelector('h3') ?
container.querySelector('h3').offsetHeight : 0;
// Legend space is 20% of height
const legendHeight = containerHeight * 0.2;
const chartHeight = containerHeight - titleHeight - legendHeight;
// margins
const margin = {
top: 5,
right: 5,
bottom: 5,
left: 5
};
// chart dimensions
const width = containerWidth - margin.left - margin.right;
const height = chartHeight - margin.top - margin.bottom;
// chart radius
const radius = Math.min(width, height) / 2 * 0.9;
// Pie chart
const svg = d3.select(container)
.append("svg")
.attr("width", "100%")
.attr("height", `${chartHeight}px`)
.attr("viewBox", `0 0 ${containerWidth} ${chartHeight}`)
.append("g")
.attr("class", "chart-group")
.attr("transform", `translate(${width / 2 + margin.left}, ${height / 2 + margin.top})`);
const sanitizedSeries = data.series.map(val => {
const numVal = Number(val);
return isNaN(numVal) ? 0 : numVal;
});
// Process data
const chartPairs = data.labels.map((label, i) => ({
label: label || "Unnamed",
value: sanitizedSeries[i] || 0,
color: (data.colors && data.colors[i]) || d3.schemeCategory10[i % 10],
url: (url_list.value && url_list.value[i]) || ''
}));
// show first maxPieSectors sectors, group the remaining in others
const topItems = [...chartPairs].slice(0, maxPieSectors);
const otherItems = [...chartPairs].slice(maxPieSectors);
const othersSum = otherItems.reduce((sum, item) => sum + item.value, 0);
let finalChartItems = [...topItems];
// add others to the chart if there is at least an element
if (otherItems.length > 0 && othersSum > 0) {
finalChartItems.push({
label: `Others (${NtopUtils.bytesToVolume(othersSum)})`, // othersSum is the total of devices count
value: othersSum,
color: "#999999", // gray for others
url: ''
});
}
if (finalChartItems.length === 0 || finalChartItems.every(item => item.value <= 0)) {
chart_data_available.value = false;
return;
}
// if label is empty add Unknown
const chartLabels = finalChartItems.map((item) => {
if (item.label.startsWith(" ")) {
return "Unknown" + item.label;
}
return item.label;
});
const chartValues = finalChartItems.map(item => item.value);
const chartColors = finalChartItems.map(item => item.color);
const chartUrls = finalChartItems.map(item => item.url);
let legendItems = [...finalChartItems];
// Prepare legend labels adding unknown to not known manufacturers
const legendLabels = legendItems.map((item) => {
if (item.label.startsWith(" ")) {
return "Unknown" + item.label;
}
return item.label;
});
const legendColors = legendItems.map(item => item.color);
const legendUrls = legendItems.map(item => item.url);
const pie = d3.pie()
.value(d => d)
.sort(null);
const pieData = pie(chartValues);
const arc = d3.arc()
.innerRadius(radius * 0.5) // For donut chart
.outerRadius(radius * 0.8);
const outerArc = d3.arc()
.innerRadius(radius * 0.5)
.outerRadius(radius * 0.85);
// Add tooltip
const tooltip = d3.select("body")
.append("div")
.attr("class", "d3-tooltip")
.style("opacity", 0)
.style("position", "absolute")
.style("background-color", "white")
.style("border", "1px solid #ddd")
.style("border-radius", "3px")
.style("padding", "5px")
.style("pointer-events", "none")
.style("z-index", "100");
// Generate pie slices
const slices = svg.selectAll(".arc")
.data(pieData)
.enter()
.append("g")
.attr("class", "arc");
// Draw paths
slices.append("path")
.attr("d", arc)
.attr("fill", (d, i) => chartColors[i])
.attr("stroke", "white")
.style("stroke-width", "2px")
.style("cursor", (d, i) => chartUrls[i] && !dataUtils.isEmptyString(chartUrls[i]) ? "pointer" : "default")
.on("mouseover", function (event, d) {
d3.select(this)
.transition()
.duration(100)
.attr("d", outerArc);
tooltip.transition()
.duration(200)
.style("opacity", 0.9);
tooltip.html(`${chartLabels[d.index]}`)
.style("left", (event.pageX) + "px")
.style("top", (event.pageY - 28) + "px");
})
.on("mouseout", function () {
d3.select(this)
.transition()
.duration(100)
.attr("d", arc);
tooltip.transition()
.duration(500)
.style("opacity", 0);
})
.on("click", function (event, d) {
if (chartUrls[d.index] && !dataUtils.isEmptyString(chartUrls[d.index])) {
window.location.href = chartUrls[d.index];
}
});
// Create the legend container
const legendContainer = d3.select(container)
.append("div")
.attr("class", "d3-legend-container")
.style("margin-top", "2px")
.style("width", "100%")
.style("max-height", `${legendHeight}px`)
.style("overflow", "hidden")
.style("display", "flex")
.style("flex-wrap", "wrap")
.style("justify-content", "center");
// Add legend items
legendLabels.forEach((label, i) => {
const legendItem = legendContainer
.append("div")
.style("display", "inline-flex")
.style("align-items", "center")
.style("margin", "1px 4px")
.style("max-width", `${containerWidth / 3 - 10}px`)
.style("cursor", legendUrls[i] && !dataUtils.isEmptyString(legendUrls[i]) ? "pointer" : "default")
.on("click", function () {
if (legendUrls[i] && !dataUtils.isEmptyString(legendUrls[i])) {
window.location.href = legendUrls[i];
}
});
legendItem.append("div")
.style("width", "10px")
.style("height", "10px")
.style("background-color", legendColors[i])
.style("margin-right", "3px")
.style("flex-shrink", "0");
legendItem.append("div")
.attr("class", "legend-text")
.text(label)
.style("font-size", "12px")
.style("white-space", "nowrap")
.style("overflow", "hidden")
.style("text-overflow", "ellipsis");
});
// Return tooltip cleanup function
return () => {
tooltip.remove();
};
}
watch(() => [props.epoch_begin, props.epoch_end, props.filters], () => {
refresh_chart();
}, { flush: 'pre', deep: true });
watch(() => props.pie_data, (cur_value, old_value) => {
refresh_chart();
});
onMounted(() => {
// set initial dimension
if (chartContainer.value && chartParent.value) {
chartContainer.value.style.width = '100%';
chartContainer.value.style.height = '100%';
chartContainer.value.style.minHeight = '300px';
resizeObserver = new ResizeObserver(() => {
if (chartData.value) {
// redraw pie chart if resize happens
nextTick(() => {
redraw_chart();
});
}
});
resizeObserver.observe(chartParent.value);
}
});
onBeforeUnmount(() => {
// Clean up resize observer
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
async function refresh_chart() {
try {
// First ensure chartContainer exists
if (!chartContainer.value) {
return;
}
const data = props.pie_data;
if (!data) {
chart_data_available.value = false;
return;
}
chartData.value = data;
// Use await with nextTick to ensure DOM is updated
await nextTick();
// Only proceed if chartContainer still exists
if (chartContainer.value) {
chartContainer.value.innerHTML = '';
drawChart(data);
}
} catch (error) {
console.error("Error fetching chart data:", error);
chart_data_available.value = false;
}
}
// for external usage
function redraw_chart() {
if (chartData.value) {
if (chartContainer.value) {
chartContainer.value.innerHTML = '';
}
drawChart(chartData.value);
}
}
const mountComponent = () => {
// set initial dimension
if (chartContainer.value && chartParent.value) {
chartContainer.value.style.width = '100%';
chartContainer.value.style.height = '100%';
chartContainer.value.style.minHeight = '300px';
resizeObserver = new ResizeObserver(() => {
if (chartData.value) {
// Use nextTick with error handling
nextTick().then(() => {
if (chartContainer.value) {
redraw_chart();
}
}).catch(err => {
console.error("Error in resize redraw:", err);
});
}
});
resizeObserver.observe(chartParent.value);
// Use a slightly longer timeout to ensure DOM is fully rendered
} else {
console.warn("Chart container or parent not available on mount");
// Try again in a moment
setTimeout(mountComponent, 100);
}
};
onMounted(() => {
mountComponent();
});
// Expose methods for external use (Match the Sankey component's approach)
defineExpose({
update_chart: refresh_chart,
redraw_chart: redraw_chart
});
</script>
<style scoped>
/* Using scoped style like in the Sankey component */
.d3-chart-container {
position: relative;
width: 100%;
height: 100%;
min-height: 300px;
display: block;
}
/* Use a flex container approach like in Sankey */
.d-flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.flex-grow-1 {
flex-grow: 1;
}
.position-relative {
position: relative;
}
.d3-legend-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-top: 10px;
}
.legend-text {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
.d3-tooltip {
position: absolute;
z-index: 1070;
font-size: 12px;
background-color: white;
border: 1px solid #ddd;
border-radius: 3px;
padding: 5px;
pointer-events: none;
}
</style>