import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs"; /* --------------------------------------------------------- TABS MANAGEMENT --------------------------------------------------------- */ function initTabs(onTabActivated) { const tabs = document.querySelectorAll(".tab"); const reports = document.querySelectorAll(".file-report"); tabs.forEach(tab => { tab.addEventListener("click", () => { const id = tab.dataset.target; // Add ripple effect const ripple = document.createElement('span'); const rect = tab.getBoundingClientRect(); const size = Math.max(rect.width, rect.height); const x = event.clientX - rect.left; const y = event.clientY - rect.top; ripple.style.cssText = ` position: absolute; border-radius: 50%; background: rgba(0, 120, 212, 0.2); transform: scale(0); animation: ripple 0.6s linear; width: ${size}px; height: ${size}px; left: ${x - size / 2}px; top: ${y - size / 2}px; `; tab.style.position = 'relative'; tab.style.overflow = 'hidden'; tab.appendChild(ripple); setTimeout(() => { if (ripple.parentElement === tab) { ripple.remove(); } }, 600); tabs.forEach(t => t.classList.remove("active")); reports.forEach(r => r.classList.remove("active")); tab.classList.add("active"); const report = document.getElementById(id); if (report) { report.classList.add("active"); // Scroll to top when switching tabs window.scrollTo({ top: 0, behavior: 'smooth' }); } if (onTabActivated) onTabActivated(id); }); }); } // Исправление для табов на странице диаграммы document.addEventListener('DOMContentLoaded', function () { const tabsContainer = document.querySelector('.tabs-container'); if (tabsContainer) { tabsContainer.style.zIndex = '1001'; tabsContainer.style.position = 'fixed'; tabsContainer.style.bottom = '0'; tabsContainer.style.left = '0'; tabsContainer.style.right = '0'; } // Гарантируем, что табы всегда поверх всего const tabs = document.querySelectorAll('.tab'); tabs.forEach(tab => { tab.style.zIndex = '1002'; }); }); /* --------------------------------------------------------- DIAGRAM VIEWER CLASS --------------------------------------------------------- */ class DiagramViewer { constructor() { this.container = document.getElementById("diagramSvgContainer"); this.minimap = document.getElementById("minimap"); this.searchInput = document.getElementById("diagramSearch"); this.resetBtn = document.getElementById("resetViewBtn"); this.svg = null; this.viewportGroup = null; this.nodes = []; this.scale = 1; this.tx = 0; this.ty = 0; this.minScale = 0.3; this.maxScale = 12; this.originalViewBox = null; this.minimapSvg = null; this.minimapViewport = null; this.needsRender = false; this.isPanning = false; this.lastSearchTerm = ''; this.searchResults = []; this.currentSearchIndex = -1; this.init(); } init() { // Add CSS for ripple animation if (!document.querySelector('#ripple-styles')) { const style = document.createElement('style'); style.id = 'ripple-styles'; style.textContent = ` @keyframes ripple { to { transform: scale(4); opacity: 0; } } @keyframes fadeIn { from {opacity: 0; transform: translateY(-10px); } to {opacity: 1; transform: translateY(0); } } `; document.head.appendChild(style); } } async render() { this.container.classList.add('loading'); try { const mermaidSource = document.getElementById("mermaidSource").textContent.trim(); const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; mermaid.initialize({ startOnLoad: false, theme: isDark ? "dark" : "default", themeVariables: isDark ? { fontFamily: "'Segoe UI', system-ui, -apple-system, sans-serif", primaryColor: "#2d2d2d", primaryBorderColor: "#2899f5", primaryTextColor: "#f3f2f1", lineColor: "#797775", lineWidth: 2.5, secondaryColor: "#3c3c3c", tertiaryColor: "#484644", background: "#1e1e1e", clusterBkg: "#201f1e", clusterBorder: "#605e5c", edgeLabelBackground: "#252423", nodeBkg: "#2d2d2d", nodeBorder: "#2899f5", nodeTextColor: "#f3f2f1", mainBkg: "#1e1e1e", textColor: "#f3f2f1", arrowheadColor: "#797775", fontSize: "14px" } : { fontFamily: "'Segoe UI', system-ui, -apple-system, sans-serif", primaryColor: "#f0f8ff", primaryBorderColor: "#0078d4", primaryTextColor: "#323130", lineColor: "#8a8886", lineWidth: 2.5, secondaryColor: "#deecf9", tertiaryColor: "#c7e0f4", background: "#ffffff", clusterBkg: "#f3f2f1", clusterBorder: "#a19f9d", edgeLabelBackground: "#ffffff", nodeBkg: "#f0f8ff", nodeBorder: "#0078d4", nodeTextColor: "#323130", mainBkg: "#ffffff", textColor: "#323130", arrowheadColor: "#8a8886", fontSize: "14px" }, flowchart: { useMaxWidth: false, htmlLabels: true, curve: "basis" } }); try { const { svg } = await mermaid.render("diagram", mermaidSource); // Убираем только встроенные стили Mermaid, оставляя структуру const cleanedSvg = svg.replace(/]*>[\s\S]*?<\/style>/gi, ""); this.container.innerHTML = cleanedSvg; this.svg = this.container.querySelector("svg"); if (this.svg) { // Добавляем небольшую задержку для гарантированной отрисовки await new Promise(resolve => setTimeout(resolve, 50)); // Добавляем классы для разных типов узлов this._addNodeClasses(); this._afterSvgLoaded(); this._initSearch(); this._attachEventListeners(); this._setupResizeObserver(); this.container.classList.remove('loading'); } } catch (error) { console.error('Mermaid rendering error:', error); this._showError(error); } } catch (error) { this.container.classList.remove('loading'); this._showError(error); } } recalculateBounds() { this._calculateRealBounds(); this._updateMinimapViewport(); } _showError(error) { this.container.innerHTML = `
📊

Не удалось загрузить диаграмму

${error.message}

`; } _addNodeClasses() { // Добавляем классы к узлам на основе текста const nodes = this.svg.querySelectorAll('.node'); nodes.forEach(node => { const textElement = node.querySelector('text'); if (textElement) { const text = textElement.textContent.toLowerCase(); if (text.includes('if') || text.includes('condition') || text.includes('условие')) { node.classList.add('condition'); } else if (text.includes('start') || text.includes('end') || text.includes('начало') || text.includes('конец')) { node.classList.add('start'); } else if (text.includes('exec') || text.includes('execute') || text.includes('выполнить') || text.includes('выполнение')) { node.classList.add('exec'); } else if (text.includes('select') || text.includes('insert') || text.includes('update') || text.includes('delete') || text.includes('выбрать') || text.includes('вставить') || text.includes('обновить') || text.includes('удалить')) { node.classList.add('process'); } else if (text.includes('import') || text.includes('export') || text.includes('импорт') || text.includes('экспорт')) { node.classList.add('import'); } } }); } _setupResizeObserver() { const resizeObserver = new ResizeObserver(() => { if (this.svg) { this._updateMinimapViewport(); } }); resizeObserver.observe(this.container); } _attachEventListeners() { // Add double click to reset zoom this.svg.addEventListener('dblclick', (e) => { e.preventDefault(); this.resetView(); }); // Add keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.target === this.searchInput || e.target.matches('input, textarea')) return; switch (e.key) { case '+': case '=': if (e.ctrlKey || e.metaKey) { e.preventDefault(); this.zoomIn(); } break; case '-': if (e.ctrlKey || e.metaKey) { e.preventDefault(); this.zoomOut(); } break; case '0': if (e.ctrlKey || e.metaKey) { e.preventDefault(); this.resetView(); } break; case 'Escape': this.clearSearch(); break; case 'F3': case 'Enter': if (e.shiftKey) { e.preventDefault(); this.prevSearchResult(); } else if (e.ctrlKey || e.metaKey) { e.preventDefault(); this.nextSearchResult(); } break; } }); // Reset button if (this.resetBtn) { this.resetBtn.addEventListener('click', () => { this.resetView(); this._showToast('Вид диаграммы сброшен', 'info'); }); } } _initSearch() { if (this.searchInput) { // Clear button const clearBtn = document.createElement('button'); clearBtn.innerHTML = '✕'; clearBtn.title = 'Очистить поиск'; clearBtn.style.cssText = ` position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 4px; font-size: 12px; opacity: 0.7; transition: opacity 0.2s; border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; `; clearBtn.addEventListener('mouseover', () => { clearBtn.style.opacity = '1'; clearBtn.style.backgroundColor = 'var(--surface-neutral)'; }); clearBtn.addEventListener('mouseout', () => { clearBtn.style.opacity = '0.7'; clearBtn.style.backgroundColor = 'transparent'; }); clearBtn.addEventListener('click', (e) => { e.stopPropagation(); this.clearSearch(); this.searchInput.value = ''; this.searchInput.focus(); this._showToast('Поиск очищен', 'info'); }); const searchContainer = this.searchInput.parentElement; searchContainer.style.position = 'relative'; searchContainer.appendChild(clearBtn); this.searchInput.addEventListener('keyup', (e) => { if (e.key === 'Enter') { const term = this.searchInput.value.trim(); if (term) { this.searchNodes(term); } else { this.clearSearch(); } } else if (e.key === 'Escape') { this.clearSearch(); this.searchInput.value = ''; } }); // Show clear button only when there's text this.searchInput.addEventListener('input', () => { clearBtn.style.display = this.searchInput.value ? 'flex' : 'none'; }); clearBtn.style.display = 'none'; } } searchNodes(term) { if (!this.svg || !term) { this.clearSearch(); return; } // Clear previous highlights this.clearSearch(); this.lastSearchTerm = term.toLowerCase(); const nodes = this.svg.querySelectorAll('.node'); this.searchResults = []; nodes.forEach((node) => { const textElement = node.querySelector('text'); if (textElement && textElement.textContent.toLowerCase().includes(this.lastSearchTerm)) { this.searchResults.push(node); } }); if (this.searchResults.length > 0) { this.currentSearchIndex = 0; this._highlightCurrentSearchResult(); this._showSearchResultsInfo(); // Center view on first result this._centerOnNode(this.searchResults[0]); } else { this._showToast('Ничего не найдено', 'warning'); } } _highlightCurrentSearchResult() { // Clear all highlights first this.searchResults.forEach((node, index) => { node.classList.remove('node-search-match', 'node-highlight', 'node-search-active'); if (index === this.currentSearchIndex) { node.classList.add('node-search-active', 'node-highlight'); } else { node.classList.add('node-search-match'); } }); } nextSearchResult() { if (this.searchResults.length === 0) return; this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length; this._highlightCurrentSearchResult(); this._centerOnNode(this.searchResults[this.currentSearchIndex]); this._updateSearchResultsInfo(); } prevSearchResult() { if (this.searchResults.length === 0) return; this.currentSearchIndex = (this.currentSearchIndex - 1 + this.searchResults.length) % this.searchResults.length; this._highlightCurrentSearchResult(); this._centerOnNode(this.searchResults[this.currentSearchIndex]); this._updateSearchResultsInfo(); } _centerOnNode(node) { if (!node || !this.svg) return; const bbox = node.getBBox(); const container = document.getElementById("diagramContainer"); // Calculate center of node in SVG coordinates const centerX = bbox.x + bbox.width / 2; const centerY = bbox.y + bbox.height / 2; // Calculate transform to center this point this.tx = container.clientWidth / 2 - centerX * this.scale; this.ty = container.clientHeight / 2 - centerY * this.scale; this._scheduleRender(); // Add a little bounce effect node.style.transform = 'scale(1.05)'; setTimeout(() => { node.style.transform = ''; }, 300); } _showSearchResultsInfo() { // Remove existing info if any this._removeSearchResultsInfo(); const info = document.createElement('div'); info.className = 'search-results-info'; info.style.cssText = ` position: absolute; top: 60px; left: 16px; background: var(--surface-default); padding: var(--spacing-s) var(--spacing-m); border-radius: var(--border-radius-medium); box-shadow: var(--shadow-16); border: 1px solid var(--border-default); font-size: 13px; z-index: 1000; animation: fadeIn 0.3s ease; display: flex; align-items: center; gap: var(--spacing-m); `; info.innerHTML = ` Найдено: ${this.searchResults.length} узлов ${this.searchResults.length > 1 ? `
${this.currentSearchIndex + 1} из ${this.searchResults.length} ` : ''} `; this.container.appendChild(info); } _updateSearchResultsInfo() { const info = this.container.querySelector('.search-results-info'); if (info && this.searchResults.length > 1) { const counter = info.querySelector('span:last-of-type'); if (counter) { counter.textContent = `${this.currentSearchIndex + 1} из ${this.searchResults.length}`; } } } _removeSearchResultsInfo() { const info = this.container.querySelector('.search-results-info'); if (info) { info.remove(); } } clearSearch() { if (this.svg) { const nodes = this.svg.querySelectorAll('.node'); nodes.forEach(node => { node.classList.remove('node-search-match', 'node-highlight', 'node-search-active'); node.style.transform = ''; }); } this.searchResults = []; this.currentSearchIndex = -1; this._removeSearchResultsInfo(); } _showToast(message, type = 'info') { const toast = document.createElement('div'); toast.textContent = message; toast.style.cssText = ` position: fixed; top: 80px; right: 20px; background: var(--surface-default); color: var(--text-primary); padding: var(--spacing-m) var(--spacing-l); border-radius: var(--border-radius-medium); box-shadow: var(--shadow-16); border: 1px solid var(--border-default); z-index: 9999; animation: fadeIn 0.3s ease; font-size: 14px; border-left: 4px solid ${type === 'error' ? '#d13438' : type === 'warning' ? '#ffaa44' : type === 'success' ? '#107c10' : 'var(--primary-color)' }; `; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(-10px)'; setTimeout(() => { if (toast.parentElement) { toast.remove(); } }, 300); }, 3000); } zoomIn() { const zoomFactor = 1.2; const newScale = Math.min(this.maxScale, this.scale * zoomFactor); this.scale = newScale; this._scheduleRender(); this._showToast(`Масштаб: ${Math.round(this.scale * 100)}%`, 'info'); } zoomOut() { const zoomFactor = 0.8; const newScale = Math.max(this.minScale, this.scale * zoomFactor); this.scale = newScale; this._scheduleRender(); this._showToast(`Масштаб: ${Math.round(this.scale * 100)}%`, 'info'); } _afterSvgLoaded() { const vb = this.svg.viewBox.baseVal; this.originalViewBox = { x: vb.x, y: vb.y, width: vb.width, height: vb.height }; this._wrapContent(); this._initZoomPan(); // Используем requestAnimationFrame для получения реальных границ после отрисовки requestAnimationFrame(() => { this._calculateRealBounds(); this._initMinimap(); this.resetView(); this._scheduleRender(); }); } _calculateRealBounds() { if (!this.viewportGroup) return; try { // Пробуем получить реальные границы содержимого let bbox; // Метод 1: Пробуем получить bbox всей группы try { bbox = this.viewportGroup.getBBox(); } catch (e) { bbox = null; } // Метод 2: Если getBBox не работает или возвращает 0, используем viewBox if (!bbox || bbox.width === 0 || bbox.height === 0 || bbox.x === Infinity || bbox.y === Infinity) { console.warn("getBBox failed, using originalViewBox"); this.diagramBounds = { ...this.originalViewBox }; return; } // Метод 3: Если getBBox работает, но границы слишком малы, проверяем ручной расчет if (bbox.width < 10 || bbox.height < 10) { // Пробуем ручной расчет через содержимое const elements = this.viewportGroup.querySelectorAll('*[x], *[y], *[cx], *[cy]'); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let hasValidCoords = false; elements.forEach(el => { let x = parseFloat(el.getAttribute('x') || el.getAttribute('cx') || '0'); let y = parseFloat(el.getAttribute('y') || el.getAttribute('cy') || '0'); let width = parseFloat(el.getAttribute('width') || '0'); let height = parseFloat(el.getAttribute('height') || '0'); let r = parseFloat(el.getAttribute('r') || '0'); if (!isNaN(x) && !isNaN(y)) { if (el.tagName === 'circle') { x = x - r; y = y - r; width = height = r * 2; } else if (el.tagName === 'ellipse') { const rx = parseFloat(el.getAttribute('rx') || '0'); const ry = parseFloat(el.getAttribute('ry') || '0'); x = x - rx; y = y - ry; width = rx * 2; height = ry * 2; } minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x + width); maxY = Math.max(maxY, y + height); hasValidCoords = true; } }); if (hasValidCoords && minX !== Infinity) { bbox = { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } } // Добавляем отступ в 5% от размера диаграммы const padding = Math.max(bbox.width, bbox.height) * 0.05; this.diagramBounds = { x: bbox.x - padding, y: bbox.y - padding, width: bbox.width + padding * 2, height: bbox.height + padding * 2 }; console.log("Diagram bounds calculated:", this.diagramBounds); } catch (error) { console.error("Error calculating diagram bounds:", error); this.diagramBounds = { ...this.originalViewBox }; } } _wrapContent() { const children = Array.from(this.svg.children); this.viewportGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); this.viewportGroup.setAttribute("id", "viewport-group"); children.forEach(child => { if (child.tagName.toLowerCase() !== "defs") { this.viewportGroup.appendChild(child.cloneNode(true)); child.remove(); } }); this.svg.appendChild(this.viewportGroup); } _initZoomPan() { let lastX = 0; let lastY = 0; let isMouseDown = false; let lastTouchDistance = 0; // Перевод dx/dy из экранных пикселей в SVG-координаты const screenDeltaToSvgDelta = (dx, dy) => { const pt1 = this.svg.createSVGPoint(); const pt2 = this.svg.createSVGPoint(); pt1.x = 0; pt1.y = 0; pt2.x = dx; pt2.y = dy; const ctm = this.svg.getScreenCTM(); if (!ctm) return { dxSvg: dx, dySvg: dy }; const inv = ctm.inverse(); const w1 = pt1.matrixTransform(inv); const w2 = pt2.matrixTransform(inv); return { dxSvg: w2.x - w1.x, dySvg: w2.y - w1.y }; }; /* --------------------------------------------------------- PAN (mouse) --------------------------------------------------------- */ this.svg.addEventListener("mousedown", (e) => { if (e.button !== 0) return; e.preventDefault(); isMouseDown = true; this.isPanning = true; lastX = e.clientX; lastY = e.clientY; this.svg.style.cursor = "grabbing"; }); window.addEventListener("mousemove", (e) => { if (!isMouseDown) return; const dxScreen = e.clientX - lastX; const dyScreen = e.clientY - lastY; const { dxSvg, dySvg } = screenDeltaToSvgDelta(dxScreen, dyScreen); this.tx += dxSvg; this.ty += dySvg; lastX = e.clientX; lastY = e.clientY; this._scheduleRender(); }); window.addEventListener("mouseup", () => { isMouseDown = false; this.isPanning = false; this.svg.style.cursor = "grab"; }); /* --------------------------------------------------------- PAN + PINCH (touch) --------------------------------------------------------- */ this.svg.addEventListener("touchstart", (e) => { if (e.touches.length === 1) { const t = e.touches[0]; lastX = t.clientX; lastY = t.clientY; this.isPanning = true; } else if (e.touches.length === 2) { this.isPanning = false; lastTouchDistance = this._getTouchDistance(e); } }, { passive: false }); this.svg.addEventListener("touchmove", (e) => { if (e.touches.length === 1 && this.isPanning) { e.preventDefault(); const t = e.touches[0]; const dxScreen = t.clientX - lastX; const dyScreen = t.clientY - lastY; const { dxSvg, dySvg } = screenDeltaToSvgDelta(dxScreen, dyScreen); this.tx += dxSvg; this.ty += dySvg; lastX = t.clientX; lastY = t.clientY; this._scheduleRender(); } else if (e.touches.length === 2) { e.preventDefault(); const newDist = this._getTouchDistance(e); const zoomFactor = newDist / lastTouchDistance; lastTouchDistance = newDist; const rect = this.svg.getBoundingClientRect(); const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left; const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top; const worldX = (midX - this.tx) / this.scale; const worldY = (midY - this.ty) / this.scale; const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * zoomFactor)); this.tx = midX - worldX * newScale; this.ty = midY - worldY * newScale; this.scale = newScale; this._scheduleRender(); } }, { passive: false }); this.svg.addEventListener("touchend", () => { this.isPanning = false; }); /* --------------------------------------------------------- ZOOM (wheel) — zoom-to-cursor --------------------------------------------------------- */ this.svg.addEventListener("wheel", (e) => { e.preventDefault(); const rect = this.svg.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * zoomFactor)); const worldX = (mouseX - this.tx) / this.scale; const worldY = (mouseY - this.ty) / this.scale; this.tx = mouseX - worldX * newScale; this.ty = mouseY - worldY * newScale; this.scale = newScale; this._scheduleRender(); }, { passive: false }); } _getTouchDistance(e) { const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; return Math.sqrt(dx * dx + dy * dy); } _getWorldRect() { const container = document.getElementById("diagramContainer"); const ctm = this.viewportGroup.getCTM(); if (!ctm) return { x: 0, y: 0, width: 100, height: 100 }; const inv = ctm.inverse(); const p1 = this.svg.createSVGPoint(); p1.x = 0; p1.y = 0; const tl = p1.matrixTransform(inv); const p2 = this.svg.createSVGPoint(); p2.x = container.clientWidth; p2.y = container.clientHeight; const br = p2.matrixTransform(inv); return { x: tl.x, y: tl.y, width: br.x - tl.x, height: br.y - tl.y }; } _applyTransform() { if (!this.viewportGroup) return; this.viewportGroup.setAttribute( "transform", `translate(${this.tx}, ${this.ty}) scale(${this.scale})` ); // ОБЯЗАТЕЛЬНО обновляем миникарту после трансформации this._updateMinimapViewport(); } _scheduleRender() { if (this.needsRender) return; this.needsRender = true; requestAnimationFrame(() => { this.needsRender = false; this._applyTransform(); }); } _initMinimap() { if (!this.minimap || !this.svg) return; this.minimap.innerHTML = ""; // Клонируем SVG для миникарты this.minimapSvg = this.svg.cloneNode(true); // Убираем трансформации с клонированных элементов const cloneViewport = this.minimapSvg.querySelector("#viewport-group"); if (cloneViewport) { cloneViewport.removeAttribute("transform"); } // Используем diagramBounds для viewBox миникарты const diagram = this.diagramBounds || this.originalViewBox; // Устанавливаем viewBox для миникарты this.minimapSvg.setAttribute( "viewBox", `${diagram.x} ${diagram.y} ${diagram.width} ${diagram.height}` ); this.minimapSvg.setAttribute("preserveAspectRatio", "xMidYMid meet"); this.minimapSvg.style.width = "100%"; this.minimapSvg.style.height = "100%"; this.minimapSvg.style.position = "absolute"; this.minimapSvg.style.opacity = "0.7"; // Убираем интерактивность this.minimapSvg.querySelectorAll('*').forEach(el => { el.style.pointerEvents = 'none'; }); this.minimap.appendChild(this.minimapSvg); // Создаем viewport элемент this.minimapViewport = document.createElement("div"); this.minimapViewport.className = "minimap-viewport"; this.minimapViewport.style.cssText = ` position: absolute; border: 2px solid var(--color-primary); background-color: rgba(0, 120, 212, 0.15); pointer-events: none; transition: all 0.1s ease; box-sizing: border-box; z-index: 10; `; this.minimap.appendChild(this.minimapViewport); // Обработчик клика по миникарте this.minimap.addEventListener("click", (e) => { if (!this.minimapSvg) return; const rect = this.minimap.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Процентное положение в миникарте const percentX = x / rect.width; const percentY = y / rect.height; const diagram = this.diagramBounds || this.originalViewBox; // Мировые координаты в диаграмме const worldX = diagram.x + diagram.width * percentX; const worldY = diagram.y + diagram.height * percentY; const container = document.getElementById("diagramContainer"); if (!container) return; // Центрируем view на точке клика this.tx = container.clientWidth / 2 - worldX * this.scale; this.ty = container.clientHeight / 2 - worldY * this.scale; this._scheduleRender(); }); } _updateMinimapViewport() { if (!this.minimapViewport || !this.minimapSvg || !this.minimap || !this.svg) { return; } try { // Получаем размеры миникарты const minimapRect = this.minimap.getBoundingClientRect(); if (minimapRect.width === 0 || minimapRect.height === 0) return; // Получаем текущую трансформацию viewportGroup const ctm = this.viewportGroup ? this.viewportGroup.getCTM() : null; if (!ctm) return; // Получаем размеры контейнера диаграммы const container = document.getElementById("diagramContainer"); if (!container) return; const containerRect = container.getBoundingClientRect(); // Рассчитываем видимую область в мировых координатах const inv = ctm.inverse(); // Левый верхний угол контейнера (0,0 в координатах SVG viewport) const topLeftScreen = this.svg.createSVGPoint(); topLeftScreen.x = 0; topLeftScreen.y = 0; const topLeftWorld = topLeftScreen.matrixTransform(inv); // Правый нижний угол контейнера const bottomRightScreen = this.svg.createSVGPoint(); bottomRightScreen.x = containerRect.width; bottomRightScreen.y = containerRect.height; const bottomRightWorld = bottomRightScreen.matrixTransform(inv); // Определяем границы видимой области const viewportWorld = { left: Math.min(topLeftWorld.x, bottomRightWorld.x), top: Math.min(topLeftWorld.y, bottomRightWorld.y), right: Math.max(topLeftWorld.x, bottomRightWorld.x), bottom: Math.max(topLeftWorld.y, bottomRightWorld.y) }; const viewportWidth = viewportWorld.right - viewportWorld.left; const viewportHeight = viewportWorld.bottom - viewportWorld.top; // Получаем границы всей диаграммы (из diagramBounds) const diagram = this.diagramBounds || this.originalViewBox; // Рассчитываем масштаб для миникарты const scaleX = minimapRect.width / diagram.width; const scaleY = minimapRect.height / diagram.height; // Преобразуем мировые координаты в координаты миникарты const viewportLeft = (viewportWorld.left - diagram.x) * scaleX; const viewportTop = (viewportWorld.top - diagram.y) * scaleY; const viewportWidthPx = viewportWidth * scaleX; const viewportHeightPx = viewportHeight * scaleY; // Применяем стили к viewport'у миникарты this.minimapViewport.style.left = `${viewportLeft}px`; this.minimapViewport.style.top = `${viewportTop}px`; this.minimapViewport.style.width = `${viewportWidthPx}px`; this.minimapViewport.style.height = `${viewportHeightPx}px`; // Для отладки (раскомментируйте если нужно) // console.log("Minimap viewport:", { // diagram, // viewportWorld, // viewportLeft, viewportTop, viewportWidthPx, viewportHeightPx, // scaleX, scaleY, // containerRect: { width: containerRect.width, height: containerRect.height } // }); } catch (error) { console.error("Error in _updateMinimapViewport:", error); } } async resetView() { const container = document.getElementById("diagramContainer"); if (!container || !this.viewportGroup) return; const cw = container.clientWidth; const ch = container.clientHeight; if (cw === 0 || ch === 0) { setTimeout(() => this.resetView(), 50); return; } // 1. Временно ставим scale = 1, чтобы измерить реальный размер диаграммы this.scale = 1; this.tx = 0; this.ty = 0; this._applyTransform(); // Даем браузеру дорендерить (важно!) await new Promise(r => requestAnimationFrame(r)); // 2. Получаем реальные пиксельные размеры диаграммы const rect = this.viewportGroup.getBoundingClientRect(); const diagramPxWidth = rect.width; const diagramPxHeight = rect.height; // 3. Вычисляем zoom-to-fit в пикселях const scaleX = cw / diagramPxWidth; const scaleY = ch / diagramPxHeight; const zoomToFit = Math.min(scaleX, scaleY) * 0.9; // 4. Устанавливаем новый пользовательский zoom this.scale = Math.max(Math.min(this.maxScale, zoomToFit), this.minScale); // 5. Центрируем диаграмму const dx = Math.max(0, (cw - diagramPxWidth * this.scale) / 2); const dy = Math.max(0, (ch - diagramPxHeight * this.scale) / 2); // 6. Преобразуем в юниты const diagram = this.diagramBounds; this.tx = diagramPxWidth / diagram.width * dx; this.ty = diagramPxHeight / diagram.height * dy; this._applyTransform(); } refresh() { this._scheduleRender(); } } /* --------------------------------------------------------- EXPORT FUNCTION --------------------------------------------------------- */ function exportPng() { const svgElement = document.querySelector('#diagramSvgContainer svg'); if (!svgElement) { alert('Диаграмма не найдена для экспорта'); return; } try { const svgData = new XMLSerializer().serializeToString(svgElement); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); // Get actual dimensions const bbox = svgElement.getBBox(); canvas.width = bbox.width * 2; canvas.height = bbox.height * 2; const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); img.onload = function () { ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const pngUrl = canvas.toDataURL('image/png'); const downloadLink = document.createElement('a'); downloadLink.href = pngUrl; downloadLink.download = `diagram_${new Date().toISOString().slice(0, 10)}.png`; document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); URL.revokeObjectURL(url); // Show success toast if (window.viewer && viewer._showToast) { viewer._showToast('Диаграмма экспортирована как PNG', 'success'); } }; img.onerror = function () { alert('Ошибка при экспорте изображения'); URL.revokeObjectURL(url); }; img.src = url; } catch (error) { console.error('Export error:', error); alert('Ошибка при экспорте: ' + error.message); } } /* --------------------------------------------------------- HANDLE SEARCH KEY PRESS --------------------------------------------------------- */ function handleSearchKeyPress(event) { if (event.key === 'Enter') { event.preventDefault(); if (window.viewer && viewer.searchInput) { const term = viewer.searchInput.value.trim(); if (term) { viewer.searchNodes(term); } else { viewer.clearSearch(); } } } } /* --------------------------------------------------------- THEME CHANGE HANDLER --------------------------------------------------------- */ function setupThemeChangeHandler() { const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); darkModeMediaQuery.addEventListener('change', (e) => { if (window.viewer && document.getElementById('mermaid').classList.contains('active')) { viewer.render().catch(console.error); } }); } /* --------------------------------------------------------- MAIN INITIALIZATION --------------------------------------------------------- */ let viewer = null; document.addEventListener("DOMContentLoaded", () => { initTabs((id) => { if (id === "mermaid") { if (!viewer) { viewer = new DiagramViewer(); viewer.render().catch(console.error); } else { viewer.refresh(); } } }); // Auto-click first tab document.querySelector(".tab.active").click(); // Setup theme change handler setupThemeChangeHandler(); // Make viewer globally available for button callbacks window.viewer = viewer; window.exportPng = exportPng; window.handleSearchKeyPress = handleSearchKeyPress; });