Files
SQLLint/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js
FrigaT f988d9af1e
All checks were successful
CI / build-test (push) Successful in 31s
Release / pack-and-publish (release) Successful in 30s
Добавлена страница Summary
2025-12-27 03:05:01 +03:00

1895 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
/* ---------------------------------------------------------
REPORT RENDERER - динамическое создание таблиц и табов
--------------------------------------------------------- */
class ReportRenderer {
constructor(data) {
this.data = data;
this.files = data.files || data.f || [];
this.rules = data.rules || data.r || {};
this.diagram = data.diagram || data.d || {};
this.currentFileIndex = 0;
this.reportsContainer = document.getElementById('reports-container');
this.tabsList = document.getElementById('tabs-list');
if (!this.reportsContainer || !this.tabsList) {
console.error('Не найдены контейнеры для отчета');
return;
}
}
init() {
this.renderTabs();
this.setupTabsDragScroll();
this.renderFileReports();
this.setupTabNavigation();
this.activateTab(0);
}
renderTabs() {
const tabsList = document.getElementById('tabs-list');
if (!tabsList) return;
this.tabsList.innerHTML = '';
//Add file tabs
this.files.forEach((file, index) => {
const tab = document.createElement('button');
tab.className = `tab ${index === 0 ? 'active' : ''}`;
tab.dataset.target = `file_${index}`;
tab.dataset.index = index;
// Подсчет нарушений по типам
const criticalCount = file.v.c?.length || 0;
const warningCount = file.v.w?.length || 0;
const infoCount = file.v.i?.length || 0;
// Создание HTML с раздельными счетчиками
const countersHTML = `
<div class="tab-counters">
<span class="tab-counter critical ${criticalCount === 0 ? 'empty' : ''}" title="${criticalCount} critical">${criticalCount || ''}</span>
<span class="tab-counter warning ${warningCount === 0 ? 'empty' : ''}" title="${warningCount} warning">${warningCount || ''}</span>
<span class="tab-counter info ${infoCount === 0 ? 'empty' : ''}" title="${infoCount} info">${infoCount || ''}</span>
</div>
`;
tab.innerHTML = `
<div class="tab-inner">
<span class="tab-text" title="${this.escapeHtml(file.n)}">${this.escapeHtml(file.n)}</span>
${countersHTML}
</div>
`;
this.tabsList.appendChild(tab);
});
// Add Summary tab
const summaryTab = document.createElement('button');
summaryTab.className = 'tab';
summaryTab.dataset.target = 'summary_report';
summaryTab.innerHTML = `<div class="tab-inner"><span class="tab-text">Summary</span></div>`;
tabsList.appendChild(summaryTab);
//Add diagram tab if exists
if (this.diagram.h || this.diagram.hasDiagram) {
const diagramTab = document.createElement('button');
diagramTab.className = 'tab';
diagramTab.dataset.target = 'mermaid';
// Создаем пустые счетчики для диаграммы
diagramTab.innerHTML = `
<div class="tab-inner">
<span class="tab-text">Диаграмма</span>
</div>
`;
tabsList.appendChild(diagramTab);
}
}
// Drag-to-scroll для табов
setupTabsDragScroll() {
const tabs = document.querySelector('.tabs');
if (!tabs) return;
let isDown = false;
let startX;
let scrollLeft;
let lastTimestamp = 0;
const SCROLL_SPEED = 1.5;
const FRAME_TIME = 16; // ~60 FPS
// Улучшенная прокрутка колесиком
tabs.addEventListener('wheel', (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
const now = Date.now();
if (now - lastTimestamp < FRAME_TIME) return; // Ограничиваем частоту
lastTimestamp = now;
// Плавная прокрутка с инерцией
tabs.style.scrollBehavior = 'auto';
const currentScroll = tabs.scrollLeft;
const targetScroll = currentScroll + e.deltaY * SCROLL_SPEED;
// Используем requestAnimationFrame для плавности
const animateScroll = () => {
const diff = targetScroll - tabs.scrollLeft;
const step = diff * 0.3; // Эффект плавного замедления
tabs.scrollLeft += step;
if (Math.abs(diff) > 0.5) {
requestAnimationFrame(animateScroll);
}
};
animateScroll();
}
}, { passive: false });
// Улучшенный drag-scroll
const startDrag = (clientX) => {
isDown = true;
tabs.style.cursor = 'grabbing';
tabs.style.scrollBehavior = 'auto';
startX = clientX - tabs.getBoundingClientRect().left;
scrollLeft = tabs.scrollLeft;
};
const endDrag = () => {
isDown = false;
tabs.style.cursor = 'grab';
};
const doDrag = (clientX) => {
if (!isDown) return;
const x = clientX - tabs.getBoundingClientRect().left;
const walk = (x - startX) * 2;
// Плавное обновление позиции
requestAnimationFrame(() => {
tabs.scrollLeft = scrollLeft - walk;
});
};
// Мышь
tabs.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // Только левая кнопка
startDrag(e.clientX);
});
tabs.addEventListener('mouseleave', endDrag);
tabs.addEventListener('mouseup', endDrag);
tabs.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
doDrag(e.clientX);
});
// Сенсорные устройства
tabs.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
startDrag(e.touches[0].clientX);
}
}, { passive: true });
tabs.addEventListener('touchend', endDrag);
tabs.addEventListener('touchcancel', endDrag);
tabs.addEventListener('touchmove', (e) => {
if (!isDown || e.touches.length !== 1) return;
e.preventDefault();
doDrag(e.touches[0].clientX);
}, { passive: false });
}
renderFileReports() {
const container = document.getElementById('reports-container');
if (!container) return;
this.reportsContainer.innerHTML = '';
// Рендеринг отчетов по файлам
this.files.forEach((file, index) => {
const reportDiv = document.createElement('div');
reportDiv.id = `file_${index}`;
reportDiv.className = 'file-report';
reportDiv.style.display = 'none';
const fileName = file.n || file.fileName || '';
const violations = file.v || file.violations || {};
// Заголовок файла
reportDiv.innerHTML = `
<div class="file-title-container">
<div class="file-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 2V8H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="file-name">${this.escapeHtml(fileName)}</span>
</div>
</div>
`;
// Добавляем секции нарушений
if (file.v.c?.length > 0) {
reportDiv.appendChild(this.createViolationSection('critical', file.v.c));
}
if (file.v.w?.length > 0) {
reportDiv.appendChild(this.createViolationSection('warning', file.v.w));
}
if (file.v.i?.length > 0) {
reportDiv.appendChild(this.createViolationSection('info', file.v.i));
}
this.reportsContainer.appendChild(reportDiv);
});
// Контейнер для диаграммы (если есть)
if (this.diagram.h || this.diagram.hasDiagram) {
const diagramContent = this.diagram.c || this.diagram.Content || '';
const diagramDiv = document.createElement('div');
diagramDiv.id = 'mermaid';
diagramDiv.className = 'file-report';
diagramDiv.style.display = 'none';
diagramDiv.innerHTML = `
<div class="diagram-toolbar">
<div class="toolbar-search">
<input type="text" id="diagramSearch" placeholder="Поиск по узлам" />
<button class="search-clear" type="button">✕</button>
</div>
<div class="toolbar-actions">
<button class="toolbar-button" type="button" onclick="exportPng()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 4px;">
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 10L12 15L17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Экспорт PNG
</button>
<button class="toolbar-button secondary" id="resetViewBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 4px;">
<path d="M3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z" stroke="currentColor" stroke-width="2"/>
<path d="M9 12L12 9L15 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 15V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Сбросить вид
</button>
</div>
</div>
<div id="diagramContainer">
<div id="diagramSvgContainer"></div>
<div id="minimap"></div>
</div>
<div id="mermaidSource" style="display:none">
${this.escapeHtml(diagramContent)}
</div>
`;
this.reportsContainer.appendChild(diagramDiv);
}
this.renderSummaryReport();
}
createViolationSection(severity, violations) {
const severityTitle = {
critical: 'Critical',
warning: 'Warning',
info: 'Info'
}[severity] || 'Unknown';
const section = document.createElement('div');
section.className = `severity-section ${severity}`;
const tableRows = violations.map(violation => {
// Получаем правило по ID
const ruleId = violation.r || violation.ruleId;
const rule = this.rules[ruleId] || { n: 'Unknown', t: 'Описание отсутствует' };
// Формируем текст с подстановкой параметров
let text = rule.t || '';
const args = violation.a || violation.args || [];
if (args.length > 0 && text.includes('{')) {
args.forEach((arg, index) => {
text = text.replace(new RegExp(`\\{${index}\\}`, 'g'), arg);
});
}
return `
<tr>
<td class="index">${violation.i || violation.index}</td>
<td class="line">${violation.l || violation.line}</td>
<td class="column">${violation.c || violation.column}</td>
<td class="rule">${this.escapeHtml(rule.n || rule.name)}</td>
<td class="description">${this.escapeHtml(text)}</td>
</tr>
`;
}).join('');
section.innerHTML = `
<div class="severity-header">
<div class="severity-title">
<h2>${severityTitle}</h2>
<span class="severity-count">${violations.length}</span>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th class="index">#</th>
<th class="line">Строка</th>
<th class="column">Колонка</th>
<th class="rule">Правило</th>
<th class="description">Описание</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
`;
return section;
}
setupTabNavigation() {
const tabs = this.tabsList.querySelectorAll('.tab');
tabs.forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
const targetId = tab.dataset.target;
const index = tab.dataset.index;
// Обновляем активный таб
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Скрываем все отчеты
document.querySelectorAll('.file-report').forEach(report => {
report.style.display = 'none';
});
// Показываем выбранный отчет
const targetReport = document.getElementById(targetId);
if (targetReport) {
targetReport.style.display = 'block';
// Если это диаграмма, инициализируем ее
if (targetId === 'mermaid' && window.viewer) {
window.viewer.render().catch(console.error);
}
// Прокрутка к верху
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
});
}
activateTab(index) {
const tab = this.tabsList.querySelector(`.tab[data-index="${index}"]`);
if (tab) {
tab.click();
}
}
calculateTotalViolations(file) {
return (file.v.c?.length || 0) + (file.v.w?.length || 0) + (file.v.i?.length || 0);
}
renderSummaryReport() {
const summaryDiv = document.createElement('div');
summaryDiv.id = 'summary_report';
summaryDiv.className = 'file-report';
summaryDiv.style.display = 'none';
// Calculate total statistics
let totalCritical = 0;
let totalWarning = 0;
let totalInfo = 0;
let totalFiles = this.files.length;
this.files.forEach(file => {
totalCritical += file.v.c?.length || 0;
totalWarning += file.v.w?.length || 0;
totalInfo += file.v.i?.length || 0;
});
const totalViolations = totalCritical + totalWarning + totalInfo;
// Calculate percentages for progress bars - FIXED
let criticalPercent = totalViolations > 0 ? (totalCritical / totalViolations * 100) : 0;
let warningPercent = totalViolations > 0 ? (totalWarning / totalViolations * 100) : 0;
let infoPercent = totalViolations > 0 ? (totalInfo / totalViolations * 100) : 0;
// Ensure sum equals 100%
const totalPercent = criticalPercent + warningPercent + infoPercent;
if (totalPercent > 0 && Math.abs(totalPercent - 100) > 0.01) {
// Adjust the largest segment to make sum = 100%
const maxVal = Math.max(criticalPercent, warningPercent, infoPercent);
if (maxVal === criticalPercent) {
criticalPercent = 100 - warningPercent - infoPercent;
} else if (maxVal === warningPercent) {
warningPercent = 100 - criticalPercent - infoPercent;
} else {
infoPercent = 100 - criticalPercent - warningPercent;
}
}
// Format for display
const criticalPercentDisplay = criticalPercent.toFixed(1);
const warningPercentDisplay = warningPercent.toFixed(1);
const infoPercentDisplay = infoPercent.toFixed(1);
summaryDiv.innerHTML = `
<div class="file-title-container">
<div class="file-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 17H7V10H9V17ZM13 12H11V17H13V12ZM17 7H15V17H17V7ZM19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="file-name">Сводный отчет</span>
</div>
</div>
<div class="summary-section">
<div class="summary-header">
<div class="summary-title">
<h2>Общая статистика</h2>
</div>
</div>
<div class="summary-stats-grid">
<div class="stat-card success">
<div class="stat-value">${totalFiles}</div>
<div class="stat-label">Всего файлов</div>
</div>
<div class="stat-card critical">
<div class="stat-value">${totalCritical}</div>
<div class="stat-label">Critical нарушений</div>
</div>
<div class="stat-card warning">
<div class="stat-value">${totalWarning}</div>
<div class="stat-label">Warning нарушений</div>
</div>
<div class="stat-card info">
<div class="stat-value">${totalInfo}</div>
<div class="stat-label">Info нарушений</div>
</div>
</div>
<div class="violation-distribution" style="margin-top: var(--spacing-xl);">
<div class="progress-bar">
<div class="progress-fill critical" style="width: ${criticalPercent}%"></div>
<div class="progress-fill warning" style="width: ${warningPercent}%"></div>
<div class="progress-fill info" style="width: ${infoPercent}%"></div>
</div>
<div style="display: flex; justify-content: space-between; margin-top: var(--spacing-sm); font-size: var(--font-size-xs);">
<span>Critical: ${totalCritical} (${criticalPercentDisplay}%)</span>
<span>Warning: ${totalWarning} (${warningPercentDisplay}%)</span>
<span>Info: ${totalInfo} (${infoPercentDisplay}%)</span>
</div>
</div>
</div>
<div class="summary-section files-overview">
<div class="summary-header">
<div class="summary-title">
<h2>Статистика файлов</h2>
<span class="severity-count">${totalFiles}</span>
</div>
</div>
<div class="files-grid">
${this.files.map(file => {
const critical = file.v.c?.length || 0;
const warning = file.v.w?.length || 0;
const info = file.v.i?.length || 0;
const total = critical + warning + info;
// Calculate percentages for file progress bar - FIXED
let fileCriticalPercent = total > 0 ? (critical / total * 100) : 0;
let fileWarningPercent = total > 0 ? (warning / total * 100) : 0;
let fileInfoPercent = total > 0 ? (info / total * 100) : 0;
// Ensure sum equals 100%
const fileTotalPercent = fileCriticalPercent + fileWarningPercent + fileInfoPercent;
if (fileTotalPercent > 0 && Math.abs(fileTotalPercent - 100) > 0.01) {
// Adjust the largest segment to make sum = 100%
const maxVal = Math.max(fileCriticalPercent, fileWarningPercent, fileInfoPercent);
if (maxVal === fileCriticalPercent) {
fileCriticalPercent = 100 - fileWarningPercent - fileInfoPercent;
} else if (maxVal === fileWarningPercent) {
fileWarningPercent = 100 - fileCriticalPercent - fileInfoPercent;
} else {
fileInfoPercent = 100 - fileCriticalPercent - fileWarningPercent;
}
}
return `<div class="file-card">
<div class="file-card-header">
<span class="file-name-small" title="${this.escapeHtml(file.n)}">${this.escapeHtml(file.n)}</span>
</div>
<div class="progress-bar">
<div class="progress-fill critical" style="width: ${fileCriticalPercent}%"></div>
<div class="progress-fill warning" style="width: ${fileWarningPercent}%"></div>
<div class="progress-fill info" style="width: ${fileInfoPercent}%"></div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: var(--spacing-xs); font-size: var(--font-size-xs);">
<span>Total: ${total}</span>
<div class="file-violations">
<span class="violation-badge critical" title="${critical} critical">${critical}</span>
<span class="violation-badge warning" title="${warning} warning">${warning}</span>
<span class="violation-badge info" title="${info} info">${info}</span>
</div>
</div>
</div>`;
}).join('')}
</div>
</div>
`;
this.reportsContainer.appendChild(summaryDiv);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
/* ---------------------------------------------------------
ГЛОБАЛЬНАЯ ИНИЦИАЛИЗАЦИЯ
--------------------------------------------------------- */
function initReport() {
if (!window.reportData) {
console.error('Данные отчета не найдены');
return;
}
const renderer = new ReportRenderer(window.reportData);
renderer.init();
// Сохраняем рендерер в глобальной области видимости
window.reportRenderer = renderer;
}
// Экспортируем функции для глобального использования
window.initReport = initReport;
/* ---------------------------------------------------------
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(/<style[^>]*>[\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 = `
<div style="
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
padding: var(--spacing-xl);
">
<div>
<div style="font-size: 48px; margin-bottom: var(--spacing-m);">📊</div>
<h3 style="margin-bottom: var(--spacing-s); color: var(--text-primary);">
Не удалось загрузить диаграмму
</h3>
<p style="color: var(--text-secondary); margin-bottom: var(--spacing-l);">
${error.message}
</p>
<button onclick="window.location.reload()" style="
padding: var(--spacing-m) var(--spacing-l);
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius-medium);
cursor: pointer;
font-family: var(--font-family);
font-weight: 600;
transition: all 0.2s ease;
" onmouseover="this.style.backgroundColor='var(--primary-dark)'"
onmouseout="this.style.backgroundColor='var(--primary-color)'">
Попробовать снова
</button>
</div>
</div>
`;
}
_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 = `
<span style="color: var(--text-primary);">
Найдено: <strong>${this.searchResults.length}</strong> узлов
</span>
${this.searchResults.length > 1 ? `
<div style="display: flex; gap: var(--spacing-xs);">
<button onclick="viewer.prevSearchResult()" style="
padding: 2px 8px;
background: var(--surface-neutral);
border: 1px solid var(--border-default);
border-radius: 2px;
cursor: pointer;
font-size: 11px;
">← Назад</button>
<button onclick="viewer.nextSearchResult()" style="
padding: 2px 8px;
background: var(--surface-neutral);
border: 1px solid var(--border-default);
border-radius: 2px;
cursor: pointer;
font-size: 11px;
">Вперёд →</button>
</div>
<span style="color: var(--text-secondary); font-size: 11px;">
${this.currentSearchIndex + 1} из ${this.searchResults.length}
</span>
` : ''}
<button onclick="viewer.clearSearch()" style="
padding: 2px 8px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 11px;
margin-left: auto;
">Закрыть</button>
`;
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", () => {
window.reportData = JSON.parse(document.getElementById('report-data').textContent);
window.hasDiagram = true;
// Инициализация отчета
if (window.initReport) {
window.initReport();
}
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;
});