// Copyright 2026 OpenObserve Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import type { I18nText } from "@/types/i18n"; import { getServiceColorHex } from "@/utils/traces/traceColors"; import { CHART_ZOOM_MAX, CHART_ZOOM_MIN } from "@/composables/traces/useChartZoom"; /** * Tree node interface for tree visualization with coordinate support */ export interface TreeNode { id: string; name: string; label?: I18nText; value: number; // Primary metric (requests/duration) errorRate?: number; children?: TreeNode[]; metadata?: Record; // Context-specific data x?: number; // Node position for coordinate-based hit detection y?: number; // Node position for coordinate-based hit detection } /** * Configuration for tree visualization rendering */ export interface TreeVisualizationConfig { layoutType: "horizontal" | "vertical"; isDarkMode: boolean; nodeSize: "fixed" | "dynamic"; symbolSize?: number; } /** * Data interface for tree visualization */ export interface TreeVisualizationData { treeData: TreeNode[]; getNodeLabel: (node: TreeNode) => string; getNodeTooltip: (node: TreeNode) => string; getNodeErrorRate: (node: TreeNode) => number; getNodeServiceColor?: (node: TreeNode) => string; } /** * Create shared tree visualization engine * Provides consistent ECharts tree configuration across ServiceGraph and TraceDetails */ export function createTreeVisualizationEngine() { /** * Generate ECharts tree options with shared styling and behavior * Reuses the proven patterns from ServiceGraph.vue tree view */ const generateEChartsOptions = ( data: TreeVisualizationData, config: TreeVisualizationConfig, ): any => { // Generate ECharts tree configuration from provided tree data const { treeData, getNodeLabel, getNodeErrorRate } = data; const { layoutType, isDarkMode, nodeSize, symbolSize = 30 } = config; /** * Create a simple circle SVG data URL with a colored border. * The border is baked into the SVG so it's always visible — ECharts tree * series doesn't reliably render itemStyle.borderColor on default symbols. * Pattern matches ServiceGraph's getServiceIconSvg in convertTraceData.ts. */ const getCircleSvg = (fillColor: string, strokeColor: string, strokeWidth: number): string => { const svg = `` + `` + ``; return `image://data:image/svg+xml;base64,${btoa(encodeURIComponent(svg).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16))))}`; }; // Convert TreeNode format to ECharts tree format const convertToEChartsFormat = (nodes: TreeNode[], parentPath: number[] = []): any[] => { return nodes.map((node, index) => { const treePath = [...parentPath, index]; const errorCount = getNodeErrorRate(node); const serviceColor = data.getNodeServiceColor?.(node) || getServiceColorHex(node.name, isDarkMode ? "dark" : "light"); const borderColor = errorCount > 0 ? isDarkMode ? "#ef4444" : "#dc2626" // Red for error spans : serviceColor; const edgeColor = isDarkMode ? "#4a5568" : "#b0b7c3"; const fillColor = isDarkMode ? "#1a1f2e" : "#ffffff"; return { name: node.name, value: node.value, // Repeated services share name and id, so the tooltip resolves the node by position. treePath, symbol: getCircleSvg(fillColor, borderColor, 4), symbolSize: nodeSize === "dynamic" ? Math.max(20, Math.min(50, Math.log10((node.value || 0) + 1) * 8)) : symbolSize, lineStyle: { color: edgeColor, width: 2, }, itemStyle: { color: isDarkMode ? "#1a1f2e" : "#ffffff", borderColor: borderColor, borderWidth: 4, // Service-color border always visible borderType: "solid", shadowBlur: 10, shadowColor: isDarkMode ? "rgba(0, 0, 0, 0.3)" : "rgba(0, 0, 0, 0.1)", shadowOffsetX: 0, shadowOffsetY: 0, opacity: 1, // Ensure full opacity }, emphasis: { scale: true, scaleSize: 1.15, itemStyle: { borderColor: borderColor, // Maintain service/error color on hover borderWidth: 5, // Slightly thicker on hover shadowBlur: 20, shadowColor: isDarkMode ? "rgba(0, 0, 0, 0.5)" : "rgba(0, 0, 0, 0.3)", }, label: { show: true, fontSize: 12, fontWeight: "bold", }, }, select: { itemStyle: { borderColor: borderColor, // Keep service/error color borderWidth: 6, // Thicker border when selected borderType: "solid", shadowBlur: 45, shadowColor: "rgba(59, 130, 246, 0.9)", // Prominent blue glow for selection shadowOffsetX: 0, shadowOffsetY: 0, opacity: 1, }, label: { show: true, fontSize: 12, fontWeight: "bold", }, }, label: { show: true, position: layoutType === "vertical" ? "bottom" : "right", distance: 6, formatter: () => getNodeLabel(node), rich: { name: { fontSize: 12, fontWeight: "600", color: isDarkMode ? "#e4e7eb" : "#1f2937", lineHeight: 16, }, requests: { fontSize: 10, fontWeight: "normal", color: isDarkMode ? "#9ca3af" : "#6b7280", lineHeight: 14, }, duration: { fontSize: 10, fontWeight: "normal", color: isDarkMode ? "#9ca3af" : "#6b7280", lineHeight: 14, }, }, }, children: node.children ? convertToEChartsFormat(node.children, treePath) : undefined, }; }); }; // Handle multiple root nodes - create virtual root if needed const echartsData = treeData.length > 1 ? [ { name: "", symbolSize: 1, itemStyle: { opacity: 0 }, label: { show: false }, lineStyle: { opacity: 0 }, children: convertToEChartsFormat(treeData), }, ] : convertToEChartsFormat(treeData); // Generate ECharts options with consistent configuration const chartOptions = { backgroundColor: "transparent", tooltip: { show: false, // Custom tooltips handled by parent components }, series: [ { type: "tree", data: echartsData, layout: "orthogonal", orient: layoutType === "vertical" ? "TB" : "LR", // Consistent spacing configuration from ServiceGraph left: layoutType === "vertical" ? "2%" : "3%", right: layoutType === "vertical" ? "2%" : "20%", top: layoutType === "vertical" ? "8%" : "2%", bottom: layoutType === "vertical" ? "8%" : "2%", initialTreeDepth: -1, symbolSize: symbolSize, roam: true, // Enable panning and zooming // Same bounds as the zoom buttons, so wheel and buttons agree. scaleLimit: { min: CHART_ZOOM_MIN, max: CHART_ZOOM_MAX }, selectedMode: "single", // Enable single node selection label: { position: layoutType === "vertical" ? "bottom" : "right", distance: 6, fontSize: 12, rotate: 0, }, leaves: { label: { position: layoutType === "vertical" ? "bottom" : "right", distance: 6, fontSize: 12, rotate: 0, }, }, emphasis: { focus: "relative", // Dims nodes not connected to hovered node }, blur: { itemStyle: { opacity: 0.15 }, label: { opacity: 0.15 }, lineStyle: { opacity: 0.08 }, }, expandAndCollapse: false, animationDuration: 550, animationDurationUpdate: 750, }, ], }; // Return the complete ECharts configuration return chartOptions; }; /** * Get health-based color using the same thresholds as ServiceGraph * Reuses exact color logic from convertServiceGraphToTree */ const getHealthColor = (errorRate: number, isDarkMode: boolean): string => { // Use more contrasting colors for better visibility if (errorRate > 10) return isDarkMode ? "#ef4444" : "#dc2626"; // Red — critical if (errorRate > 5) return isDarkMode ? "#f97316" : "#ea580c"; // Orange — warning if (errorRate > 1) return isDarkMode ? "#fbbf24" : "#d97706"; // Yellow — degraded // Healthy nodes - use more contrasting green return isDarkMode ? "#10b981" : "#16a34a"; // Green — healthy (more contrasting) }; /** * Find chart node at screen coordinates using ECharts hit detection */ const findNodeAtPoint = ( chart: any, point: [number, number], treeData: TreeNode[], ): TreeNode | null => { try { // Use ECharts' built-in hit detection for accurate node detection const zr = chart.getZr(); if (!zr || !zr.handler) { return null; } // Get the element at the mouse position const hoveredElements = zr.handler.findHover(point[0], point[1]); if (!hoveredElements || !hoveredElements.target) return null; // Check if we hit a tree node symbol const target = hoveredElements.target; const dataIndex = target.dataIndex; if (dataIndex === undefined || dataIndex === null) return null; // Find the corresponding tree node using the data index const foundNode = findNodeByIndex(treeData, dataIndex); return foundNode; } catch (error) { console.warn("Error in findNodeAtPoint:", error); return null; } }; const findNodeByPath = (nodes: TreeNode[], treePath: number[]): TreeNode | null => { let level: TreeNode[] | undefined = nodes; let node: TreeNode | null = null; for (const index of treePath) { node = level?.[index] ?? null; if (!node) return null; level = node.children; } return node; }; /** * Helper to find tree node by ECharts data index (fallback method) * Traverses the tree data to find the node at the given index */ const findNodeByIndex = (nodes: TreeNode[], targetIndex: number): TreeNode | null => { let currentIndex = 0; const traverse = (nodeList: TreeNode[]): TreeNode | null => { for (const node of nodeList) { if (currentIndex === targetIndex) { return node; } currentIndex++; if (node.children && node.children.length > 0) { const found = traverse(node.children); if (found) return found; } } return null; }; return traverse(nodes); }; /** * Setup custom DOM tooltips for trace graph nodes * Follows exact Service Graph tooltip patterns for consistency */ const setupTraceNodeTooltips = ( chart: any, data: TreeVisualizationData, isDarkMode: boolean, ): (() => void) => { const chartDom = chart.getDom(); // Create tooltip element with Service Graph styling const tooltipEl = document.createElement("div"); // Exact styling from Service Graph implementation /* eslint-disable local/no-hardcoded-px -- optical effects: backdrop blur radii and a 1-device-pixel border, which must not scale with text */ tooltipEl.style.cssText = ` position: absolute; pointer-events: none; z-index: 9999; background: ${isDarkMode ? "rgba(22, 22, 26, 0.90)" : "rgba(255, 255, 255, 0.88)"}; backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: blur(24px) saturate(180%); border: 1px solid ${isDarkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)"}; border-radius: 0.5rem; padding: 0.5625rem 0.8125rem; font-size: 0.75rem; line-height: 1.5; font-family: var(--font-sans); letter-spacing: 0.01em; white-space: nowrap; display: none; color: ${isDarkMode ? "rgba(255,255,255,0.88)" : "rgba(0,0,0,0.82)"}; `; /* eslint-enable local/no-hardcoded-px */ if (!chartDom.style.position || chartDom.style.position === "static") { chartDom.style.position = "relative"; } chartDom.appendChild(tooltipEl); let hideTimer: ReturnType | null = null; let activeNode: TreeNode | null = null; // Positioning logic from Service Graph with proper dimension measurement const positionTooltip = (mouseX: number, mouseY: number) => { const cw = chartDom.clientWidth; const ch = chartDom.clientHeight; let left = mouseX + 15; let top = mouseY + 15; // Make visible but positioned off-screen to measure dimensions tooltipEl.style.visibility = "hidden"; tooltipEl.style.display = "block"; tooltipEl.style.left = "0"; tooltipEl.style.top = "0"; // Now we can get accurate dimensions const tooltipWidth = tooltipEl.offsetWidth; const tooltipHeight = tooltipEl.offsetHeight; // Adjust position if tooltip would go off-screen if (left + tooltipWidth > cw) { left = mouseX - tooltipWidth - 10; } if (top + tooltipHeight > ch) { top = mouseY - tooltipHeight - 10; } // Ensure tooltip doesn't go negative left = Math.max(5, left); top = Math.max(5, top); // Position and make visible tooltipEl.style.left = left + "px"; tooltipEl.style.top = top + "px"; tooltipEl.style.visibility = "visible"; }; const showNodeTooltip = (mouseX: number, mouseY: number, node: TreeNode) => { const tooltipContent = data.getNodeTooltip(node); tooltipEl.innerHTML = tooltipContent; positionTooltip(mouseX, mouseY); }; const hideTooltip = () => { tooltipEl.style.display = "none"; activeNode = null; }; // ECharts event handling - use built-in hover events directly on chart elements const onNodeMouseOver = (params: any) => { if (!params?.data?.treePath) return; const hoveredNode = findNodeByPath(data.treeData, params.data.treePath); if (hoveredNode && hoveredNode !== activeNode) { // Clear any existing timer to prevent race conditions if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } activeNode = hoveredNode; // Use event pixel coordinates if available, otherwise use chart center const mouseX = params.event?.offsetX || chartDom.clientWidth / 2; const mouseY = params.event?.offsetY || chartDom.clientHeight / 2; showNodeTooltip(mouseX, mouseY, hoveredNode); } }; const onNodeMouseOut = () => { if (activeNode) { // Clear existing timer before setting new one if (hideTimer) { clearTimeout(hideTimer); } hideTimer = setTimeout(() => { hideTimer = null; hideTooltip(); }, 150); } }; const onChartMouseLeave = () => { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } hideTooltip(); }; // Register ECharts events directly on chart elements chart.on("mouseover", onNodeMouseOver); chart.on("mouseout", onNodeMouseOut); chart.on("globalout", onChartMouseLeave); // Cleanup function return () => { if (hideTimer) clearTimeout(hideTimer); chart.off("mouseover", onNodeMouseOver); chart.off("mouseout", onNodeMouseOut); chart.off("globalout", onChartMouseLeave); tooltipEl.remove(); }; }; return { generateEChartsOptions, setupTraceNodeTooltips, findNodeAtPoint, // Export for testing findNodeByIndex, // Export helper for testing getHealthColor, }; } /** * Placeholder tree-options API for ServiceGraph.vue — not yet implemented. */ export const createLegacyTreeOptions = ( graphData: { nodes: any[]; edges: any[] }, _layoutType: string = "horizontal", _isDarkMode: boolean = true, ) => { throw new Error( "Legacy tree options not yet implemented - use convertServiceGraphToTree for now", ); };