Files
SQLLint/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js

1614 lines
59 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.renderFileReports();
this.setupTabNavigation();
this.activateTab(0);
}
renderTabs() {
const tabsList = document.getElementById('tabs-list');
if (!tabsList) return;
this.tabsList.innerHTML = '';
// Табы для файлов
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 total = (file.v.c?.length || 0) + (file.v.w?.length || 0) + (file.v.i?.length || 0);
tab.innerHTML = `
${this.escapeHtml(file.n)}
<span class="tab-badge">${total}</span>
`;
this.tabsList.appendChild(tab);
});
// Таб для диаграммы (если есть)
if (this.diagram.h || this.diagram.hasDiagram) {
const diagramTab = document.createElement('button');
diagramTab.className = 'tab';
diagramTab.dataset.target = 'mermaid';
diagramTab.textContent = 'Диаграмма';
tabsList.appendChild(diagramTab);
}
}
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);
}
}
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">
<h3>${severityTitle}</h3>
<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.criticalViolations?.length || 0) +
(file.warningViolations?.length || 0) +
(file.infoViolations?.length || 0);
}
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;
});