1614 lines
59 KiB
JavaScript
1614 lines
59 KiB
JavaScript
|
||
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;
|
||
}); |