diff --git a/SQLLinter.CLI/Program.cs b/SQLLinter.CLI/Program.cs index 6d6590f..683fe38 100644 --- a/SQLLinter.CLI/Program.cs +++ b/SQLLinter.CLI/Program.cs @@ -44,7 +44,7 @@ namespace SQLLinter.CLI ["UpperLower"] = Common.RuleViolationSeverity.Critical, ["SetVariable"] = Common.RuleViolationSeverity.Critical, }, - GenerateDetails = false, + GenerateDetails = true, }; //var linter = new Linter(con, rep); @@ -62,7 +62,7 @@ namespace SQLLinter.CLI Dictionary files = new(); - for (int i = 0; i < 2; i++) + for (int i = 0; i < 15; i++) { files[name + i + ".sql"] = reader.BaseStream; } diff --git a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.css b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.css index 99518a7..1b0e93d 100644 --- a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.css +++ b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.css @@ -1926,26 +1926,13 @@ svg .flowchart-link { } } -/* Контейнер для блока кода */ -.code-block { - position: relative; - background: var(--surface-default); - border: 1px solid var(--border-default); - border-radius: var(--border-radius-medium); - margin: var(--spacing-m) 0; - overflow: hidden; - font-family: 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 13px; - line-height: 1.5; -} - /* Контейнер для блока кода */ .code-block { position: relative; background: var(--color-background); border: 1px solid var(--color-border); border-radius: var(--border-radius-medium); - margin: var(--spacing-md) 0; + margin: var(--spacing-md); overflow: hidden; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: var(--font-size-sm); @@ -1964,6 +1951,7 @@ svg .flowchart-link { min-height: 24px; padding: 0 var(--spacing-sm); transition: background-color var(--transition-fast); + border-left: 3px solid transparent; } .code-line:hover { @@ -2050,7 +2038,7 @@ svg .flowchart-link { } /* Строка с ошибкой */ -.code-line.error-line { +.code-line.critical-line { background: linear-gradient(90deg, rgba(209, 52, 56, 0.05) 0%, rgba(209, 52, 56, 0.1) 100%); border-left: 3px solid var(--color-critical); } @@ -2103,7 +2091,7 @@ svg .flowchart-link { border-color: var(--color-success); } - .code-line.error-line { + .code-line.critical-line { background: linear-gradient(90deg, rgba(209, 52, 56, 0.1) 0%, rgba(209, 52, 56, 0.15) 100%); } diff --git a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.js b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.js index e366a5c..9a50d49 100644 --- a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.js +++ b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.js @@ -1,8 +1,7 @@ - -import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs"; +import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs"; /* --------------------------------------------------------- - REPORT RENDERER - динамическое создание таблиц и табов + REPORT RENDERER - оптимизированная версия --------------------------------------------------------- */ class ReportRenderer { constructor(data) { @@ -10,8 +9,6 @@ class ReportRenderer { 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'); @@ -24,189 +21,162 @@ class ReportRenderer { init() { this.renderTabs(); this.setupTabsDragScroll(); - this.renderFileReports(); - this.setupTabNavigation(); + this.renderAllReports(); + this.setupEventListeners(); this.activateTab(0); } + setupEventListeners() { + // Делегирование для кнопок деталей + document.addEventListener('click', (e) => { + const toggleBtn = e.target.closest('.detail-toggle-btn'); + const closeBtn = e.target.closest('.detail-close-btn'); + + if (toggleBtn) { + e.preventDefault(); + this.toggleDetail(toggleBtn.dataset.detailId, toggleBtn); + } else if (closeBtn) { + e.preventDefault(); + this.hideDetail(closeBtn.dataset.detailId); + } + }); + + // Переключение табов (делегирование) + this.tabsList.addEventListener('click', (e) => { + const tab = e.target.closest('.tab'); + if (!tab) return; + + e.preventDefault(); + const targetId = tab.dataset.target; + if (!targetId) return; + + this.activateTabById(targetId); + this.tabsList.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab)); + + if (targetId === 'mermaid' && window.viewer) { + window.viewer.render().catch(console.error); + } + }); + } + + activateTabById(targetId) { + document.querySelectorAll('.file-report').forEach(report => { + report.classList.toggle('active', report.id === targetId); + report.style.display = report.id === targetId ? 'block' : 'none'; + }); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + activateTab(index) { + const tab = this.tabsList.querySelector(`.tab[data-index="${index}"]`); + tab?.click(); + } + renderTabs() { - const tabsList = document.getElementById('tabs-list'); - if (!tabsList) return; + const fragment = document.createDocumentFragment(); - this.tabsList.innerHTML = ''; + this.files.forEach((file, i) => { + const critical = file.v?.c?.length || 0; + const warning = file.v?.w?.length || 0; - //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 = ` -
- ${criticalCount || ''} - ${warningCount || ''} -
- `; + tab.className = `tab ${i === 0 ? 'active' : ''}`; + tab.dataset.target = `file_${i}`; + tab.dataset.index = i; tab.innerHTML = `
${this.escapeHtml(file.n)} - ${countersHTML} +
+ ${critical || ''} + ${warning || ''} +
`; - - this.tabsList.appendChild(tab); + fragment.appendChild(tab); }); - // Add Summary tab + // Summary tab const summaryTab = document.createElement('button'); summaryTab.className = 'tab'; summaryTab.dataset.target = 'summary_report'; summaryTab.innerHTML = `
Summary
`; - tabsList.appendChild(summaryTab); + fragment.appendChild(summaryTab); - //Add diagram tab if exists + // Diagram tab if (this.diagram.h || this.diagram.hasDiagram) { const diagramTab = document.createElement('button'); diagramTab.className = 'tab'; diagramTab.dataset.target = 'mermaid'; - // Создаем пустые счетчики для диаграммы - - diagramTab.innerHTML = ` -
- Диаграмма -
- `; - - tabsList.appendChild(diagramTab); + diagramTab.innerHTML = `
Диаграмма
`; + fragment.appendChild(diagramTab); } + + this.tabsList.innerHTML = ''; + this.tabsList.appendChild(fragment); } - // 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 + let isDragging = false; + let startX, scrollLeft; - // Улучшенная прокрутка колесиком - 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; + const start = (x) => { + isDragging = true; tabs.style.cursor = 'grabbing'; tabs.style.scrollBehavior = 'auto'; - startX = clientX - tabs.getBoundingClientRect().left; + startX = x - tabs.getBoundingClientRect().left; scrollLeft = tabs.scrollLeft; }; - const endDrag = () => { - isDown = false; + const move = (x) => { + if (!isDragging) return; + const walk = (x - tabs.getBoundingClientRect().left - startX) * 2; + tabs.scrollLeft = scrollLeft - walk; + }; + + const end = () => { + isDragging = false; tabs.style.cursor = 'grab'; }; - const doDrag = (clientX) => { - if (!isDown) return; + // Mouse + tabs.addEventListener('mousedown', e => e.button === 0 && start(e.clientX)); + tabs.addEventListener('mousemove', e => move(e.clientX)); + tabs.addEventListener('mouseup', end); + tabs.addEventListener('mouseleave', end); - 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; + // Touch + tabs.addEventListener('touchstart', e => e.touches.length === 1 && start(e.touches[0].clientX), { passive: true }); + tabs.addEventListener('touchmove', e => { + if (e.touches.length !== 1 || !isDragging) return; e.preventDefault(); - doDrag(e.clientX); - }); + move(e.touches[0].clientX); + }, { passive: false }); + tabs.addEventListener('touchend', end); - // Сенсорные устройства - tabs.addEventListener('touchstart', (e) => { - if (e.touches.length === 1) { - startDrag(e.touches[0].clientX); + // Wheel — горизонтальная прокрутка + tabs.addEventListener('wheel', e => { + if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + e.preventDefault(); + tabs.scrollLeft += e.deltaY; } - }, { 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; + renderAllReports() { + const containerFragment = document.createDocumentFragment(); - this.reportsContainer.innerHTML = ''; - - // Рендеринг отчетов по файлам - this.files.forEach((file, index) => { + // File reports + this.files.forEach((file, i) => { const reportDiv = document.createElement('div'); - reportDiv.id = `file_${index}`; + reportDiv.id = `file_${i}`; reportDiv.className = 'file-report'; - reportDiv.style.display = 'none'; + reportDiv.style.display = i === 0 ? 'block' : 'none'; - const fileName = file.n || file.fileName || ''; - const violations = file.v || file.violations || {}; - - // Заголовок файла + const fileName = this.escapeHtml(file.n || ''); reportDiv.innerHTML = `
@@ -214,142 +184,86 @@ class ReportRenderer { - ${this.escapeHtml(fileName)} + ${fileName}
`; - // Добавляем секции нарушений - 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)); - } + ['c', 'w', 'i'].forEach(severity => { + const violations = file.v?.[severity]; + if (violations?.length > 0) { + reportDiv.appendChild(this.createViolationSection(severity, violations)); + } + }); - this.reportsContainer.appendChild(reportDiv); + containerFragment.appendChild(reportDiv); }); - // Контейнер для диаграммы (если есть) + // Summary + containerFragment.appendChild(this.renderSummaryReport()); + + // Diagram 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 = ` -
- -
- - -
-
-
-
-
-
- - `; - - this.reportsContainer.appendChild(diagramDiv); + diagramDiv.innerHTML = this.getDiagramHTML(); + containerFragment.appendChild(diagramDiv); } - this.renderSummaryReport(); + this.reportsContainer.innerHTML = ''; + this.reportsContainer.appendChild(containerFragment); + } + + getDiagramHTML() { + const content = this.escapeHtml(this.diagram.c || this.diagram.Content || ''); + return ` +
+ +
+ + +
+
+
+
+
+
+ + `; } createViolationSection(severity, violations) { - const severityTitle = { - critical: 'Critical', - warning: 'Warning', - info: 'Info' - }[severity] || 'Unknown'; - + const titles = { c: 'Critical', w: 'Warning', i: 'Info' }; + const severitySections = { c: 'critical', w: 'warning', i: 'info' }; const section = document.createElement('div'); - section.className = `severity-section ${severity}`; + section.className = `severity-section ${severitySections[severity]}`; - // Проверяем, есть ли хотя бы одно нарушение с деталями const hasDetails = violations.some(v => v.d || v.details); - - const tableRows = violations.map(violation => { - // Получаем правило по ID - const ruleId = violation.r || violation.ruleId; - const rule = this.rules[ruleId] || { n: 'Unknown', t: 'Описание отсутствует' }; - - // Получаем детальное описание - const details = violation.d || violation.details || ''; - - // Формируем основной текст с подстановкой параметров - 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'), this.escapeHtml(arg)); - }); - } - - // Уникальный ID для раскрывающейся секции - const detailId = `detail_${severity}_${violation.i || violation.index}`; - - return ` - - ${violation.i || violation.index} - ${violation.l || violation.line} - ${violation.c || violation.column} - ${this.escapeHtml(rule.n || rule.name)} - -
- ${this.escapeHtml(text)} - ${details ? - `` : - '' - } -
- - - ${details ? ` - - - ${details} - - - ` : ''} - `; - }).join(''); + const rows = violations.map(v => this.createViolationRow(v, severity)).join(''); section.innerHTML = `
-

${severityTitle}

+

${titles[severity]}

${violations.length}
@@ -364,85 +278,82 @@ class ReportRenderer { Описание - - ${tableRows} - + ${rows} `; - - // Добавляем обработчики событий после создания секции - this.addDetailHandlers(section); - return section; } - addDetailHandlers(section) { - // Обработчик для кнопок раскрытия/закрытия деталей - section.addEventListener('click', (e) => { - const toggleBtn = e.target.closest('.detail-toggle-btn'); - const closeBtn = e.target.closest('.detail-close-btn'); + createViolationRow(violation, severity) { + const rule = this.rules[violation.r || violation.ruleId] || { n: 'Unknown', t: 'Описание отсутствует' }; + let text = rule.t || ''; + const args = violation.a || violation.args || []; + if (args.length && text.includes('{')) { + args.forEach((arg, i) => { + text = text.replace(new RegExp(`\\{${i}\\}`, 'g'), this.escapeHtml(arg)); + }); + } - if (toggleBtn) { - const detailId = toggleBtn.dataset.detailId; - this.toggleDetail(detailId, toggleBtn); - } + const detailId = `detail_${severity}_${violation.i || violation.index}`; + const details = violation.d || violation.details || ''; - if (closeBtn) { - const detailId = closeBtn.dataset.detailId; - this.hideDetail(detailId); - } - }); - - // Закрытие по нажатию Escape - section.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - const openDetails = section.querySelectorAll('.violation-detail-row[style="display: table-row;"]'); - openDetails.forEach(detailRow => { - const detailId = detailRow.id; - this.hideDetail(detailId); - }); - } - }); + return ` + + ${violation.i || violation.index} + ${violation.l || violation.line} + ${violation.c || violation.column} + ${this.escapeHtml(rule.n || rule.name)} + +
+ ${this.escapeHtml(text)} + ${details ? `` : ''} +
+ + + ${details ? ` + + ${details} + ` : ''} + `; } toggleDetail(detailId, toggleBtn) { - const detailRow = document.getElementById(detailId); - const mainRow = toggleBtn.closest('.violation-main-row'); + // Находим текущий видимый отчёт (активную вкладку) + const activeReport = document.querySelector('.file-report.active'); - if (!detailRow || !mainRow) return; + if (!activeReport) return; + + // Ищем строку детали ТОЛЬКО внутри активного отчёта + const detailRow = activeReport.querySelector(`#${detailId}`); + if (!detailRow) return; + + const mainRow = toggleBtn.closest('.violation-main-row'); + if (!mainRow || !activeReport.contains(mainRow)) return; // защита от "чужих" кнопок const isVisible = detailRow.style.display === 'table-row'; if (isVisible) { this.hideDetail(detailId); - } else { - // Закрываем другие открытые детали в этой же таблице - const table = detailRow.closest('table'); - if (table) { - const otherDetails = table.querySelectorAll('.violation-detail-row[style="display: table-row;"]'); - otherDetails.forEach(row => { - if (row.id !== detailId) { - this.hideDetail(row.id); - } - }); - } - - // Показываем текущую деталь - detailRow.style.display = 'table-row'; - - // Обновляем состояние кнопки - const icon = toggleBtn.querySelector('.detail-toggle-icon'); - if (icon) { - icon.style.transform = 'rotate(180deg)'; - } - toggleBtn.classList.add('active'); - - // Прокручиваем к детали - setTimeout(() => { - detailRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }, 10); + return; } + + // Показываем текущую + detailRow.style.display = 'table-row'; + toggleBtn.classList.add('active'); + + const icon = toggleBtn.querySelector('.detail-toggle-icon'); + if (icon) { + icon.style.transform = 'rotate(180deg)'; + } + + // Прокрутка к детали + setTimeout(() => { + detailRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 10); } hideDetail(detailId) { @@ -451,211 +362,108 @@ class ReportRenderer { detailRow.style.display = 'none'; - // Обновляем кнопку const toggleBtn = document.querySelector(`.detail-toggle-btn[data-detail-id="${detailId}"]`); if (toggleBtn) { + toggleBtn.classList.remove('active'); const icon = toggleBtn.querySelector('.detail-toggle-icon'); if (icon) { icon.style.transform = 'rotate(0deg)'; } - toggleBtn.classList.remove('active'); } } - 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'; + const div = document.createElement('div'); + div.id = 'summary_report'; + div.className = 'file-report'; + div.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; + let totalCritical = 0, totalWarning = 0, totalInfo = 0; + this.files.forEach(f => { + totalCritical += f.v?.c?.length || 0; + totalWarning += f.v?.w?.length || 0; + totalInfo += f.v?.i?.length || 0; }); - const totalViolations = totalCritical + totalWarning + totalInfo; + const total = totalCritical + totalWarning + totalInfo; + const percents = total > 0 ? [ + (totalCritical / total * 100), + (totalWarning / total * 100), + (totalInfo / total * 100) + ] : [0, 0, 0]; - // 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; - } + // Корректировка до 100% + const sum = percents.reduce((a, b) => a + b, 0); + if (sum > 0 && Math.abs(sum - 100) > 0.1) { + const maxIdx = percents.indexOf(Math.max(...percents)); + percents[maxIdx] = 100 - percents.reduce((a, b, i) => i !== maxIdx ? a + b : a, 0); } - // Format for display - const criticalPercentDisplay = criticalPercent.toFixed(1); - const warningPercentDisplay = warningPercent.toFixed(1); - const infoPercentDisplay = infoPercent.toFixed(1); - - summaryDiv.innerHTML = ` -
-
- - - - Сводный отчет -
-
-
-
-
-

Общая статистика

-
-
-
-
-
${totalFiles}
-
Всего файлов
-
-
-
${totalCritical}
-
Critical нарушений
-
-
-
${totalWarning}
-
Warning нарушений
-
-
-
${totalInfo}
-
Info нарушений
-
-
-
-
-
-
-
-
-
- Critical: ${totalCritical} (${criticalPercentDisplay}%) - Warning: ${totalWarning} (${warningPercentDisplay}%) - Info: ${totalInfo} (${infoPercentDisplay}%) -
-
-
-
-
-
-

Статистика файлов

- ${totalFiles} -
-
-
- ${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; - } + const fileCards = this.files.map(file => { + const c = file.v?.c?.length || 0; + const w = file.v?.w?.length || 0; + const i = file.v?.i?.length || 0; + const t = c + w + i; + const fp = t > 0 ? [c / t * 100, w / t * 100, i / t * 100] : [0, 0, 0]; + const fsum = fp.reduce((a, b) => a + b, 0); + if (fsum > 0 && Math.abs(fsum - 100) > 0.1) { + const maxI = fp.indexOf(Math.max(...fp)); + fp[maxI] = 100 - fp.reduce((a, b, j) => j !== maxI ? a + b : a, 0); } - return `
-
- ${this.escapeHtml(file.n)} -
-
-
-
-
-
-
- Всего: ${total} -
- ${critical} - ${warning} - ${info} -
-
-
`; - }).join('')} -
-
- `; +
${this.escapeHtml(file.n)}
+
+
+
+
+
+
+ Всего: ${t} +
+ ${c} + ${w} + ${i} +
+
+ `; + }).join(''); - this.reportsContainer.appendChild(summaryDiv); + div.innerHTML = ` +
+
+ + + + Сводный отчет +
+
+
+

Общая статистика

+
+
${this.files.length}
Всего файлов
+
${totalCritical}
Critical
+
${totalWarning}
Warning
+
${totalInfo}
Info
+
+
+
+
+
+
+
+
+ Critical: ${totalCritical} (${percents[0].toFixed(1)}%) + Warning: ${totalWarning} (${percents[1].toFixed(1)}%) + Info: ${totalInfo} (${percents[2].toFixed(1)}%) +
+
+
+
+

Статистика файлов

${this.files.length}
+
${fileCards}
+
+ `; + return div; } escapeHtml(text) { @@ -666,103 +474,7 @@ class ReportRenderer { } /* --------------------------------------------------------- - ГЛОБАЛЬНАЯ ИНИЦИАЛИЗАЦИЯ ---------------------------------------------------------- */ -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 + DiagramViewer — упрощена и оптимизирована --------------------------------------------------------- */ class DiagramViewer { constructor() { @@ -773,1122 +485,565 @@ class DiagramViewer { this.svg = null; this.viewportGroup = null; - this.nodes = []; + this.diagramBounds = null; + this.originalViewBox = null; this.scale = 1; this.tx = 0; this.ty = 0; - this.minScale = 0.3; - this.maxScale = 12; + this.minScale = 0.2; + this.maxScale = 10; - this.originalViewBox = null; - this.minimapSvg = null; - this.minimapViewport = null; - - this.needsRender = false; this.isPanning = false; - this.lastSearchTerm = ''; + this.needsRender = false; + this.renderRequested = false; + this.searchResults = []; this.currentSearchIndex = -1; + this.lastSearchTerm = ""; 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; - } - } + this.addRippleStyles(); + this.attachGlobalListeners(); + this.setupSearch(); + } - @keyframes fadeIn { - from {opacity: 0; transform: translateY(-10px); } - to {opacity: 1; transform: translateY(0); } - } - `; - document.head.appendChild(style); + addRippleStyles() { + if (document.getElementById('diagram-viewer-styles')) return; + + const style = document.createElement('style'); + style.id = 'diagram-viewer-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); } } + .node-search-match { filter: brightness(1.3); } + .node-search-active { filter: brightness(1.5); outline: 3px solid var(--primary-color); outline-offset: 2px; } + .minimap-viewport { transition: all 0.15s ease; } + `; + document.head.appendChild(style); + } + + attachGlobalListeners() { + if (this.resetBtn) { + this.resetBtn.addEventListener('click', () => this.resetView()); } + + // Двойной клик — сброс вида + this.container.addEventListener('dblclick', (e) => { + e.preventDefault(); + this.resetView(); + }); + + // Горячие клавиши + document.addEventListener('keydown', (e) => { + if (e.target.matches('input, textarea')) return; + + if ((e.ctrlKey || e.metaKey)) { + if (e.key === '=' || e.key === '+') { e.preventDefault(); this.zoomIn(); } + if (e.key === '-') { e.preventDefault(); this.zoomOut(); } + if (e.key === '0') { e.preventDefault(); this.resetView(); } + } + if (e.key === 'Escape') this.clearSearch(); + if (e.key === 'Enter' && e.shiftKey) this.prevSearchResult(); + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) this.nextSearchResult(); + }); + } + + setupSearch() { + if (!this.searchInput) return; + + const container = this.searchInput.parentElement; + container.style.position = 'relative'; + + const clearBtn = document.createElement('button'); + clearBtn.textContent = '✕'; + clearBtn.className = 'search-clear'; + clearBtn.type = 'button'; + 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: 14px; border-radius: 50%; + width: 24px; height: 24px; display: none; align-items: center; justify-content: center; + `; + + clearBtn.addEventListener('click', () => { + this.searchInput.value = ''; + this.clearSearch(); + this.searchInput.focus(); + clearBtn.style.display = 'none'; + }); + + container.appendChild(clearBtn); + + let searchTimeout; + this.searchInput.addEventListener('input', () => { + clearBtn.style.display = this.searchInput.value ? 'flex' : 'none'; + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + const term = this.searchInput.value.trim(); + term ? this.searchNodes(term) : this.clearSearch(); + }, 300); + }); + + this.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.nextSearchResult(); + } + }); } async render() { this.container.classList.add('loading'); - - try { + const sourceEl = document.getElementById("mermaidSource"); + const mermaidSource = sourceEl?.textContent.trim() || ''; + + if (!mermaidSource) throw new Error("Исходный код диаграммы не найден"); - 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" - } + themeVariables: this.getThemeVariables(isDark), + flowchart: { useMaxWidth: false, htmlLabels: true, curve: "basis" } }); - try { - const { svg } = await mermaid.render("diagram", mermaidSource); + const { svg } = await mermaid.render("diagram", mermaidSource); + const cleanedSvg = svg.replace(/]*>[\s\S]*?<\/style>/gi, ""); - // Убираем только встроенные стили Mermaid, оставляя структуру - const cleanedSvg = svg.replace(/]*>[\s\S]*?<\/style>/gi, ""); + this.container.innerHTML = cleanedSvg; + this.svg = this.container.querySelector("svg"); - this.container.innerHTML = cleanedSvg; - this.svg = this.container.querySelector("svg"); + if (!this.svg) throw new Error("SVG не был сгенерирован"); - if (this.svg) { - // Добавляем небольшую задержку для гарантированной отрисовки - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(r => setTimeout(r, 50)); // дать браузеру отрисовать - // Добавляем классы для разных типов узлов - this._addNodeClasses(); - this._afterSvgLoaded(); - this._initSearch(); - this._attachEventListeners(); - this._setupResizeObserver(); - this.container.classList.remove('loading'); - } - } catch (error) { - console.error('Mermaid rendering error:', error); - this._showError(error); - } - } catch (error) { - this.container.classList.remove('loading'); - this._showError(error); - } - } - - recalculateBounds() { - this._calculateRealBounds(); - this._updateMinimapViewport(); - } - - _showError(error) { - this.container.innerHTML = ` -
-
-
📊
-

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

-

- ${error.message} -

- -
-
- `; - } - - _addNodeClasses() { - // Добавляем классы к узлам на основе текста - const nodes = this.svg.querySelectorAll('.node'); - nodes.forEach(node => { - const textElement = node.querySelector('text'); - if (textElement) { - const text = textElement.textContent.toLowerCase(); - - if (text.includes('if') || text.includes('condition') || text.includes('условие')) { - node.classList.add('condition'); - } else if (text.includes('start') || text.includes('end') || - text.includes('начало') || text.includes('конец')) { - node.classList.add('start'); - } else if (text.includes('exec') || text.includes('execute') || - text.includes('выполнить') || text.includes('выполнение')) { - node.classList.add('exec'); - } else if (text.includes('select') || text.includes('insert') || - text.includes('update') || text.includes('delete') || - text.includes('выбрать') || text.includes('вставить') || - text.includes('обновить') || text.includes('удалить')) { - node.classList.add('process'); - } else if (text.includes('import') || text.includes('export') || - text.includes('импорт') || text.includes('экспорт')) { - node.classList.add('import'); - } - } - }); - } - - _setupResizeObserver() { - const resizeObserver = new ResizeObserver(() => { - if (this.svg) { - this._updateMinimapViewport(); - } - }); - - resizeObserver.observe(this.container); - } - - _attachEventListeners() { - // Add double click to reset zoom - this.svg.addEventListener('dblclick', (e) => { - e.preventDefault(); + this.addNodeClasses(); + this.wrapContentInGroup(); + this.calculateBounds(); + this.initInteractions(); + this.initMinimap(); this.resetView(); - }); - // Add keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.target === this.searchInput || e.target.matches('input, textarea')) return; + this.container.classList.remove('loading'); + } catch (err) { + console.error("Mermaid render error:", err); + this.showError(err.message || "Не удалось отрисовать диаграмму"); + } + } - 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; + getThemeVariables(isDark) { + return isDark + ? { + fontFamily: "'Segoe UI', system-ui, sans-serif", + primaryColor: "#2d2d2d", primaryBorderColor: "#2899f5", primaryTextColor: "#f3f2f1", + lineColor: "#797775", background: "#1e1e1e", nodeBkg: "#2d2d2d", nodeBorder: "#2899f5", + nodeTextColor: "#f3f2f1", arrowheadColor: "#797775" } + : { + fontFamily: "'Segoe UI', system-ui, sans-serif", + primaryColor: "#f0f8ff", primaryBorderColor: "#0078d4", primaryTextColor: "#323130", + lineColor: "#8a8886", background: "#ffffff", nodeBkg: "#f0f8ff", nodeBorder: "#0078d4", + nodeTextColor: "#323130", arrowheadColor: "#8a8886" + }; + } + + addNodeClasses() { + this.svg.querySelectorAll('.node').forEach(node => { + const text = node.textContent?.toLowerCase() || ''; + if (/if|condition|условие/.test(text)) node.classList.add('condition'); + else if (/start|end|начало|конец/.test(text)) node.classList.add('start'); + else if (/exec|execute|выполнить/.test(text)) node.classList.add('exec'); + else if (/select|insert|update|delete|выбрать|вставить|обновить|удалить/.test(text)) node.classList.add('process'); + else if (/import|export|импорт|экспорт/.test(text)) node.classList.add('import'); + }); + } + + wrapContentInGroup() { + const children = Array.from(this.svg.children).filter(c => c.tagName.toLowerCase() !== "defs"); + this.viewportGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); + this.viewportGroup.id = "viewport-group"; + + children.forEach(child => { + this.viewportGroup.appendChild(child.cloneNode(true)); + child.remove(); }); - // Reset button - if (this.resetBtn) { - this.resetBtn.addEventListener('click', () => { - this.resetView(); - this._showToast('Вид диаграммы сброшен', 'info'); - }); - } + this.svg.appendChild(this.viewportGroup); + + const vb = this.svg.viewBox.baseVal; + this.originalViewBox = { x: vb.x, y: vb.y, width: vb.width, height: vb.height }; } - _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'; + calculateBounds() { + let bbox; + try { + bbox = this.viewportGroup.getBBox(); + if (bbox.width === 0 || bbox.height === 0) throw new Error("Invalid bbox"); + } catch { + bbox = this.originalViewBox; } + + 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 + }; } + initInteractions() { + let startX, startY, startTx, startTy, startScale, touchDist; + + const startPan = (clientX, clientY) => { + this.isPanning = true; + startX = clientX; + startY = clientY; + startTx = this.tx; + startTy = this.ty; + this.svg.style.cursor = 'grabbing'; + }; + + const movePan = (clientX, clientY) => { + if (!this.isPanning) return; + this.tx = startTx + (clientX - startX); + this.ty = startTy + (clientY - startY); + this.scheduleRender(); + }; + + const endPan = () => { + this.isPanning = false; + this.svg.style.cursor = 'grab'; + }; + + // Mouse + this.svg.addEventListener('mousedown', e => e.button === 0 && startPan(e.clientX, e.clientY)); + window.addEventListener('mousemove', e => movePan(e.clientX, e.clientY)); + window.addEventListener('mouseup', endPan); + + // Touch + this.svg.addEventListener('touchstart', e => { + if (e.touches.length === 1) { + const t = e.touches[0]; + startPan(t.clientX, t.clientY); + } else if (e.touches.length === 2) { + this.isPanning = false; + touchDist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + startScale = this.scale; + } + e.preventDefault(); + }, { passive: false }); + + this.svg.addEventListener('touchmove', e => { + if (e.touches.length === 1 && this.isPanning) { + const t = e.touches[0]; + movePan(t.clientX, t.clientY); + } else if (e.touches.length === 2) { + const newDist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + 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 newScale = Math.max(this.minScale, Math.min(this.maxScale, startScale * (newDist / touchDist))); + this.scale = newScale; + this.tx = midX - (midX - this.tx) * (newScale / startScale); + this.ty = midY - (midY - this.ty) * (newScale / startScale); + this.scheduleRender(); + } + e.preventDefault(); + }, { passive: false }); + + this.svg.addEventListener('touchend', endPan); + + // Wheel zoom to cursor + this.svg.addEventListener('wheel', e => { + e.preventDefault(); + const rect = this.svg.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const factor = e.deltaY < 0 ? 1.15 : 0.85; + const newScale = Math.max(this.minScale, Math.min(this.maxScale, this.scale * factor)); + + this.tx = x - (x - this.tx) * (newScale / this.scale); + this.ty = y - (y - this.ty) * (newScale / this.scale); + this.scale = newScale; + this.scheduleRender(); + }, { passive: false }); + } + + initMinimap() { + if (!this.minimap || !this.svg) return; + + this.minimap.innerHTML = ''; + + const clone = this.svg.cloneNode(true); + const cloneGroup = clone.querySelector("#viewport-group"); + if (cloneGroup) cloneGroup.removeAttribute("transform"); + + clone.setAttribute("viewBox", `${this.diagramBounds.x} ${this.diagramBounds.y} ${this.diagramBounds.width} ${this.diagramBounds.height}`); + clone.style.cssText = "width:100%; height:100%; position:absolute; opacity:0.7;"; + clone.querySelectorAll('*').forEach(el => el.style.pointerEvents = 'none'); + + this.minimap.appendChild(clone); + + this.minimapViewportRect = document.createElement("div"); + this.minimapViewportRect.className = "minimap-viewport"; + this.minimapViewportRect.style.cssText = ` + position:absolute; border:2px solid var(--primary-color); + background:rgba(0,120,212,0.15); pointer-events:none; + box-sizing:border-box; z-index:10; + `; + this.minimap.appendChild(this.minimapViewportRect); + + this.minimap.addEventListener('click', e => { + const rect = this.minimap.getBoundingClientRect(); + const px = (e.clientX - rect.left) / rect.width; + const py = (e.clientY - rect.top) / rect.height; + + const worldX = this.diagramBounds.x + this.diagramBounds.width * px; + const worldY = this.diagramBounds.y + this.diagramBounds.height * py; + + const container = document.getElementById("diagramContainer"); + this.tx = container.clientWidth / 2 - worldX * this.scale; + this.ty = container.clientHeight / 2 - worldY * this.scale; + this.scheduleRender(); + }); + + // Обновляем миникарту при ресайзе окна/контейнера + new ResizeObserver(() => this.updateMinimapViewport()).observe(document.getElementById("diagramContainer")); + } + + updateMinimapViewport() { + if (!this.minimapViewportRect || !this.viewportGroup) return; + + const container = document.getElementById("diagramContainer"); + if (!container) return; + + const ctm = this.viewportGroup.getCTM(); + if (!ctm) return; + + const inv = ctm.inverse(); + const tl = this.svg.createSVGPoint(); tl.x = 0; tl.y = 0; tl = tl.matrixTransform(inv); + const br = this.svg.createSVGPoint(); br.x = container.clientWidth; br.y = container.clientHeight; br = br.matrixTransform(inv); + + const visible = { + left: Math.min(tl.x, br.x), + top: Math.min(tl.y, br.y), + width: Math.abs(br.x - tl.x), + height: Math.abs(br.y - tl.y) + }; + + const scaleX = this.minimap.clientWidth / this.diagramBounds.width; + const scaleY = this.minimap.clientHeight / this.diagramBounds.height; + + this.minimapViewportRect.style.left = `${(visible.left - this.diagramBounds.x) * scaleX}px`; + this.minimapViewportRect.style.top = `${(visible.top - this.diagramBounds.y) * scaleY}px`; + this.minimapViewportRect.style.width = `${visible.width * scaleX}px`; + this.minimapViewportRect.style.height = `${visible.height * scaleY}px`; + } + + scheduleRender() { + if (this.renderRequested) return; + this.renderRequested = true; + requestAnimationFrame(() => { + this.renderRequested = false; + this.applyTransform(); + this.updateMinimapViewport(); + }); + } + + applyTransform() { + if (!this.viewportGroup) return; + this.viewportGroup.setAttribute("transform", `translate(${this.tx},${this.ty}) scale(${this.scale})`); + } + + async resetView() { + const container = document.getElementById("diagramContainer"); + if (!container || !this.viewportGroup) return; + + // Временно сбрасываем трансформацию для точного измерения + this.scale = 1; + this.tx = 0; + this.ty = 0; + this.applyTransform(); + + await new Promise(r => requestAnimationFrame(r)); + + const contentRect = this.viewportGroup.getBoundingClientRect(); + const cw = container.clientWidth; + const ch = container.clientHeight; + + const scaleX = cw / contentRect.width; + const scaleY = ch / contentRect.height; + this.scale = Math.max(this.minScale, Math.min(this.maxScale, Math.min(scaleX, scaleY) * 0.92)); + + this.tx = (cw - contentRect.width * this.scale) / 2; + this.ty = (ch - contentRect.height * this.scale) / 2; + + this.scheduleRender(); + this.showToast('Вид сброшен', 'info'); + } + + zoomIn() { this.scale = Math.min(this.maxScale, this.scale * 1.25); this.scheduleRender(); this.showToast(`Масштаб: ${Math.round(this.scale * 100)}%`); } + zoomOut() { this.scale = Math.max(this.minScale, this.scale * 0.8); this.scheduleRender(); this.showToast(`Масштаб: ${Math.round(this.scale * 100)}%`); } + searchNodes(term) { - if (!this.svg || !term) { - this.clearSearch(); - return; - } - - // Clear previous highlights + if (!this.svg) return; 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)) { + nodes.forEach(node => { + const textEl = node.querySelector('text'); + if (textEl && textEl.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'); + if (this.searchResults.length === 0) { + this.showToast('Ничего не найдено', 'warning'); + return; } + + this.currentSearchIndex = 0; + this.highlightCurrentResult(); + this.centerOnNode(this.searchResults[0]); + this.showSearchInfo(); } - _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'); - } + highlightCurrentResult() { + this.searchResults.forEach((node, i) => { + node.classList.toggle('node-search-active', i === this.currentSearchIndex); + node.classList.toggle('node-search-match', i !== this.currentSearchIndex); }); } 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(); + this.highlightCurrentResult(); + this.centerOnNode(this.searchResults[this.currentSearchIndex]); + this.updateSearchInfo(); } 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(); + this.highlightCurrentResult(); + this.centerOnNode(this.searchResults[this.currentSearchIndex]); + this.updateSearchInfo(); } - _centerOnNode(node) { + 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(); + this.scheduleRender(); - // Add a little bounce effect - node.style.transform = 'scale(1.05)'; - setTimeout(() => { - node.style.transform = ''; - }, 300); + // Плавный эффект выделения + node.style.transition = 'transform 0.3s'; + node.style.transform = 'scale(1.1)'; + setTimeout(() => node.style.transform = '', 300); } - _showSearchResultsInfo() { - // Remove existing info if any - this._removeSearchResultsInfo(); + clearSearch() { + this.searchResults.forEach(node => { + node.classList.remove('node-search-match', 'node-search-active'); + node.style.transform = ''; + }); + this.searchResults = []; + this.currentSearchIndex = -1; + this.removeSearchInfo(); + } + + showSearchInfo() { + this.removeSearchInfo(); + if (this.searchResults.length <= 1) return; 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); + position:absolute; top:60px; left:16px; background:var(--surface-default); + padding:8px 12px; border-radius:8px; box-shadow:var(--shadow-16); + border:1px solid var(--border-default); font-size:13px; z-index:1000; + display:flex; align-items:center; gap:12px; animation:fadeIn 0.3s; `; - info.innerHTML = ` - - Найдено: ${this.searchResults.length} узлов - - ${this.searchResults.length > 1 ? ` -
- - -
- - ${this.currentSearchIndex + 1} из ${this.searchResults.length} - - ` : ''} - + Найдено: ${this.searchResults.length} + ${this.currentSearchIndex + 1} / ${this.searchResults.length} + + + `; - this.container.appendChild(info); } - _updateSearchResultsInfo() { - const info = this.container.querySelector('.search-results-info'); - if (info && this.searchResults.length > 1) { - const counter = info.querySelector('span:last-of-type'); - if (counter) { - counter.textContent = `${this.currentSearchIndex + 1} из ${this.searchResults.length}`; - } - } + updateSearchInfo() { + const info = this.container.querySelector('.search-results-info span'); + if (info) info.textContent = `${this.currentSearchIndex + 1} / ${this.searchResults.length}`; } - _removeSearchResultsInfo() { - const info = this.container.querySelector('.search-results-info'); - if (info) { - info.remove(); - } + removeSearchInfo() { + this.container.querySelector('.search-results-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') { + 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)' - }; - `; - + position:fixed; top:80px; right:20px; padding:12px 20px; + background:var(--surface-default); color:var(--text-primary); + border-radius:8px; box-shadow:var(--shadow-16); z-index:9999; + border-left:4px solid ${type === 'success' ? '#107c10' : type === 'warning' ? '#ffaa44' : 'var(--primary-color)'}; + animation:fadeIn 0.3s; + `; document.body.appendChild(toast); - setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(-10px)'; - setTimeout(() => { - if (toast.parentElement) { - toast.remove(); - } - }, 300); - }, 3000); + setTimeout(() => toast.remove(), 300); + }, 2500); } - 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(); + showError(message) { + this.container.classList.remove('loading'); + this.container.innerHTML = ` +
+
📊
+

Ошибка загрузки диаграммы

+

${message}

+ +
+ `; } } /* --------------------------------------------------------- - EXPORT FUNCTION + EXPORT FUNCTION — ВОЗВРАЩАЕМ ОБРАТНО! --------------------------------------------------------- */ function exportPng() { const svgElement = document.querySelector('#diagramSvgContainer svg'); @@ -1903,18 +1058,23 @@ function exportPng() { const ctx = canvas.getContext('2d'); const img = new Image(); - // Get actual dimensions + // Получаем реальные размеры содержимого SVG const bbox = svgElement.getBBox(); - canvas.width = bbox.width * 2; - canvas.height = bbox.height * 2; + const padding = 20; // небольшой отступ + canvas.width = (bbox.width + padding * 2) * 2; // x2 для retina-качества + canvas.height = (bbox.height + padding * 2) * 2; + + // Белый фон для светлой темы (опционально) + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); 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); - + ctx.drawImage(img, padding * 2, padding * 2, bbox.width * 2, bbox.height * 2); const pngUrl = canvas.toDataURL('image/png'); + const downloadLink = document.createElement('a'); downloadLink.href = pngUrl; downloadLink.download = `diagram_${new Date().toISOString().slice(0, 10)}.png`; @@ -1924,14 +1084,14 @@ function exportPng() { URL.revokeObjectURL(url); - // Show success toast - if (window.viewer && viewer._showToast) { - viewer._showToast('Диаграмма экспортирована как PNG', 'success'); + // Тост об успехе + if (window.viewer && typeof window.viewer._showToast === 'function') { + window.viewer._showToast('Диаграмма экспортирована как PNG', 'success'); } }; img.onerror = function () { - alert('Ошибка при экспорте изображения'); + alert('Ошибка при загрузке SVG в изображение'); URL.revokeObjectURL(url); }; @@ -1943,69 +1103,54 @@ function exportPng() { } /* --------------------------------------------------------- - 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(); +function initReport() { + if (!window.reportData) { + console.error('Данные отчета не найдены'); + return; } - - initTabs((id) => { - if (id === "mermaid") { - if (!viewer) { - viewer = new DiagramViewer(); - viewer.render().catch(console.error); - } else { - viewer.refresh(); - } + const renderer = new ReportRenderer(window.reportData); + renderer.init(); + window.reportRenderer = renderer; + + // Инициализация диаграммы при первом открытии вкладки + if (window.reportData.diagram?.h || window.reportData.diagram?.hasDiagram) { + viewer = new DiagramViewer(); + window.viewer = viewer; + } +} + +window.initReport = initReport; +window.exportPng = exportPng; + +document.addEventListener('DOMContentLoaded', () => { + window.reportData = JSON.parse(document.getElementById('report-data').textContent); + + initReport(); + + // Автоклик первой вкладки + document.querySelector('.tab.active')?.click(); + + // Фикс позиционирования табов + const tabsContainer = document.querySelector('.tabs-container'); + if (tabsContainer) { + Object.assign(tabsContainer.style, { + position: 'fixed', + bottom: '0', + left: '0', + right: '0', + zIndex: '1001' + }); + } + + // Перерендер при смене темы + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (viewer && document.getElementById('mermaid')?.style.display === 'block') { + viewer.render().catch(console.error); } }); - - // 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; }); \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlReportFormatter_v2.cs b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlReportFormatter_v2.cs index 6997ab1..99d7d9d 100644 --- a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlReportFormatter_v2.cs +++ b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlReportFormatter_v2.cs @@ -225,7 +225,7 @@ public class HtmlReportFormatter : IReportFormatter var lineClass = "code-line"; if (isErrorLine) { - lineClass += " error-line"; + lineClass += $" {errorSeverity}-line"; } sb.Append($"
");