511 lines
15 KiB
JavaScript
511 lines
15 KiB
JavaScript
let workbook = null;
|
||
let worksheet = null;
|
||
let rawData = [];
|
||
let fileName = '';
|
||
|
||
const uploadArea = document.getElementById('upload-area');
|
||
const fileInput = document.getElementById('file-input');
|
||
const fileInfo = document.getElementById('file-info');
|
||
const file_name = document.getElementById('file-name');
|
||
const fileMeta = document.getElementById('file-meta');
|
||
const analyzeBtn = document.getElementById('analyze-btn');
|
||
const errorMessage = document.getElementById('error-message');
|
||
const loading = document.getElementById('loading');
|
||
const results = document.getElementById('results');
|
||
|
||
uploadArea.addEventListener('click', () => fileInput.click());
|
||
|
||
uploadArea.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
fileInput.click();
|
||
}
|
||
});
|
||
|
||
uploadArea.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
uploadArea.classList.add('dragover');
|
||
});
|
||
|
||
uploadArea.addEventListener('dragleave', () => {
|
||
uploadArea.classList.remove('dragover');
|
||
});
|
||
|
||
uploadArea.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
uploadArea.classList.remove('dragover');
|
||
const files = e.dataTransfer.files;
|
||
if (files.length > 0) {
|
||
handleFile(files[0]);
|
||
}
|
||
});
|
||
|
||
fileInput.addEventListener('change', (e) => {
|
||
if (e.target.files.length > 0) {
|
||
handleFile(e.target.files[0]);
|
||
}
|
||
});
|
||
|
||
function handleFile(file) {
|
||
const validTypes = [
|
||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
'application/vnd.ms-excel',
|
||
'text/csv',
|
||
'application/vnd.ms-excel.sheet.macroEnabled.12'
|
||
];
|
||
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
const isValid = validTypes.includes(file.type) ||
|
||
['xlsx', 'xls', 'csv'].includes(ext);
|
||
|
||
if (!isValid) {
|
||
showError('Неподдерживаемый формат файла. Загрузите .xlsx, .xls или .csv');
|
||
return;
|
||
}
|
||
|
||
fileName = file.name;
|
||
file_name.textContent = '📄 ' + file.name;
|
||
fileMeta.textContent = 'Размер: ' + formatFileSize(file.size);
|
||
fileInfo.classList.add('visible');
|
||
uploadArea.classList.add('has-file');
|
||
analyzeBtn.disabled = false;
|
||
hideError();
|
||
results.classList.remove('visible');
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const data = new Uint8Array(e.target.result);
|
||
workbook = XLSX.read(data, { type: 'array' });
|
||
|
||
if (workbook.SheetNames.length > 0) {
|
||
worksheet = workbook.Sheets[workbook.SheetNames[0]];
|
||
rawData = XLSX.utils.sheet_to_json(worksheet, { defval: '' });
|
||
|
||
if (rawData.length === 0) {
|
||
showError('Файл пуст или не содержит данных');
|
||
analyzeBtn.disabled = true;
|
||
return;
|
||
}
|
||
|
||
analyzeBtn.disabled = false;
|
||
}
|
||
} catch (err) {
|
||
showError('Ошибка чтения файла: ' + err.message);
|
||
analyzeBtn.disabled = true;
|
||
}
|
||
};
|
||
reader.onerror = () => {
|
||
showError('Ошибка при чтении файла');
|
||
analyzeBtn.disabled = true;
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
}
|
||
|
||
analyzeBtn.addEventListener('click', () => {
|
||
if (rawData.length === 0) {
|
||
showError('Нет данных для анализа');
|
||
return;
|
||
}
|
||
|
||
loading.classList.add('visible');
|
||
results.classList.remove('visible');
|
||
|
||
setTimeout(() => {
|
||
try {
|
||
performAnalysis();
|
||
loading.classList.remove('visible');
|
||
results.classList.add('visible');
|
||
results.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
} catch (err) {
|
||
loading.classList.remove('visible');
|
||
showError('Ошибка анализа: ' + err.message);
|
||
}
|
||
}, 500);
|
||
});
|
||
|
||
function performAnalysis() {
|
||
const columns = Object.keys(rawData[0]);
|
||
const stats = calculateStats(rawData, columns);
|
||
const insights = generateInsights(rawData, columns, stats);
|
||
|
||
renderStats(stats);
|
||
renderInsights(insights);
|
||
renderCharts(rawData, columns, stats);
|
||
renderTable(rawData, columns);
|
||
}
|
||
|
||
function calculateStats(data, columns) {
|
||
const stats = {
|
||
totalRows: data.length,
|
||
totalColumns: columns.length,
|
||
columns: columns,
|
||
emptyCells: 0,
|
||
numericColumns: [],
|
||
textColumns: [],
|
||
dateColumns: [],
|
||
columnStats: {}
|
||
};
|
||
|
||
columns.forEach(col => {
|
||
const values = data.map(row => row[col]).filter(v => v !== '' && v !== null && v !== undefined);
|
||
const emptyCount = data.length - values.length;
|
||
stats.emptyCells += emptyCount;
|
||
|
||
const firstValue = values[0];
|
||
const isNumeric = values.every(v => !isNaN(parseFloat(v)) && isFinite(v));
|
||
const isDate = values.some(v => {
|
||
const d = new Date(v);
|
||
return !isNaN(d.getTime());
|
||
});
|
||
|
||
if (isNumeric) {
|
||
stats.numericColumns.push(col);
|
||
const numValues = values.map(v => parseFloat(v));
|
||
stats.columnStats[col] = {
|
||
type: 'numeric',
|
||
min: Math.min(...numValues),
|
||
max: Math.max(...numValues),
|
||
avg: numValues.reduce((a, b) => a + b, 0) / numValues.length,
|
||
sum: numValues.reduce((a, b) => a + b, 0),
|
||
empty: emptyCount
|
||
};
|
||
} else if (isDate) {
|
||
stats.dateColumns.push(col);
|
||
stats.columnStats[col] = {
|
||
type: 'date',
|
||
empty: emptyCount
|
||
};
|
||
} else {
|
||
stats.textColumns.push(col);
|
||
const unique = [...new Set(values)];
|
||
const counts = {};
|
||
values.forEach(v => { counts[v] = (counts[v] || 0) + 1; });
|
||
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
||
stats.columnStats[col] = {
|
||
type: 'text',
|
||
unique: unique.length,
|
||
top: sorted.slice(0, 5),
|
||
empty: emptyCount
|
||
};
|
||
}
|
||
});
|
||
|
||
return stats;
|
||
}
|
||
|
||
function generateInsights(data, columns, stats) {
|
||
const insights = [];
|
||
|
||
insights.push({
|
||
icon: '📊',
|
||
text: `<strong>Всего записей:</strong> ${stats.totalRows} в ${stats.totalColumns} колонках`
|
||
});
|
||
|
||
if (stats.numericColumns.length > 0) {
|
||
insights.push({
|
||
icon: '💰',
|
||
text: `<strong>Числовые колонки:</strong> ${stats.numericColumns.join(', ')}`
|
||
});
|
||
|
||
stats.numericColumns.forEach(col => {
|
||
const s = stats.columnStats[col];
|
||
if (s && s.type === 'numeric') {
|
||
insights.push({
|
||
icon: '📈',
|
||
text: `<strong>${col}:</strong> среднее = ${formatNumber(s.avg)}, мин = ${formatNumber(s.min)}, макс = ${formatNumber(s.max)}`
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
if (stats.textColumns.length > 0) {
|
||
insights.push({
|
||
icon: '🏷️',
|
||
text: `<strong>Текстовые колонки:</strong> ${stats.textColumns.join(', ')}`
|
||
});
|
||
|
||
stats.textColumns.forEach(col => {
|
||
const s = stats.columnStats[col];
|
||
if (s && s.type === 'text' && s.top.length > 0) {
|
||
const topList = s.top.map(item => item[0]).slice(0, 3).join(', ');
|
||
insights.push({
|
||
icon: '🔝',
|
||
text: `<strong>${col}:</strong> чаще всего встречается "${topList}"`
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
const emptyPercent = ((stats.emptyCells / (stats.totalRows * stats.totalColumns)) * 100).toFixed(1);
|
||
if (emptyPercent > 10) {
|
||
insights.push({
|
||
icon: '⚠️',
|
||
text: `<strong>Внимание:</strong> ${emptyPercent}% пустых ячеек — проверьте полноту данных`
|
||
});
|
||
}
|
||
|
||
return insights;
|
||
}
|
||
|
||
function renderStats(stats) {
|
||
const grid = document.getElementById('stats-grid');
|
||
grid.innerHTML = `
|
||
<div class="stat-card">
|
||
<div class="stat-value">${stats.totalRows}</div>
|
||
<div class="stat-label">Всего записей</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${stats.totalColumns}</div>
|
||
<div class="stat-label">Колонок</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${stats.numericColumns.length}</div>
|
||
<div class="stat-label">Числовых полей</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${formatNumber(stats.emptyCells)}</div>
|
||
<div class="stat-label">Пустых ячеек</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderInsights(insights) {
|
||
const list = document.getElementById('insights-list');
|
||
list.innerHTML = insights.map(i => `
|
||
<div class="insight-item">
|
||
<div class="insight-icon">${i.icon}</div>
|
||
<div class="insight-text">${i.text}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
let charts = [];
|
||
|
||
function renderCharts(data, columns, stats) {
|
||
const container = document.getElementById('charts-section');
|
||
container.innerHTML = '';
|
||
|
||
charts.forEach(chart => chart.destroy());
|
||
charts = [];
|
||
|
||
if (stats.numericColumns.length >= 2) {
|
||
const chartCard = document.createElement('div');
|
||
chartCard.className = 'chart-card';
|
||
chartCard.innerHTML = `
|
||
<h3>Распределение числовых данных</h3>
|
||
<div class="chart-container">
|
||
<canvas id="barChart"></canvas>
|
||
</div>
|
||
`;
|
||
container.appendChild(chartCard);
|
||
|
||
const ctx = document.getElementById('barChart').getContext('2d');
|
||
const chart = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: stats.numericColumns.slice(0, 10),
|
||
datasets: [{
|
||
label: 'Среднее значение',
|
||
data: stats.numericColumns.slice(0, 10).map(col =>
|
||
stats.columnStats[col] ? stats.columnStats[col].avg : 0
|
||
),
|
||
backgroundColor: 'rgba(99, 102, 241, 0.6)',
|
||
borderColor: 'rgba(99, 102, 241, 1)',
|
||
borderWidth: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true
|
||
}
|
||
}
|
||
}
|
||
});
|
||
charts.push(chart);
|
||
}
|
||
|
||
if (stats.textColumns.length > 0 && stats.textColumns.some(col =>
|
||
stats.columnStats[col] && stats.columnStats[col].unique <= 10
|
||
)) {
|
||
const textCol = stats.textColumns.find(col =>
|
||
stats.columnStats[col] && stats.columnStats[col].unique <= 10
|
||
);
|
||
|
||
if (textCol) {
|
||
const s = stats.columnStats[textCol];
|
||
const chartCard = document.createElement('div');
|
||
chartCard.className = 'chart-card';
|
||
chartCard.innerHTML = `
|
||
<h3>Распределение: ${textCol}</h3>
|
||
<div class="chart-container">
|
||
<canvas id="pieChart"></canvas>
|
||
</div>
|
||
`;
|
||
container.appendChild(chartCard);
|
||
|
||
const ctx = document.getElementById('pieChart').getContext('2d');
|
||
const chart = new Chart(ctx, {
|
||
type: 'pie',
|
||
data: {
|
||
labels: s.top.map(item => item[0]),
|
||
datasets: [{
|
||
data: s.top.map(item => item[1]),
|
||
backgroundColor: [
|
||
'rgba(99, 102, 241, 0.8)',
|
||
'rgba(16, 185, 129, 0.8)',
|
||
'rgba(245, 158, 11, 0.8)',
|
||
'rgba(239, 68, 68, 0.8)',
|
||
'rgba(139, 92, 246, 0.8)'
|
||
]
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom'
|
||
}
|
||
}
|
||
}
|
||
});
|
||
charts.push(chart);
|
||
}
|
||
}
|
||
|
||
if (stats.numericColumns.length >= 1 && data.length <= 100) {
|
||
const chartCard = document.createElement('div');
|
||
chartCard.className = 'chart-card';
|
||
chartCard.innerHTML = `
|
||
<h3>Динамика данных</h3>
|
||
<div class="chart-container">
|
||
<canvas id="lineChart"></canvas>
|
||
</div>
|
||
`;
|
||
container.appendChild(chartCard);
|
||
|
||
const firstNumeric = stats.numericColumns[0];
|
||
const values = data.slice(0, 50).map((_, i) =>
|
||
data[i] && data[i][firstNumeric] ? parseFloat(data[i][firstNumeric]) : 0
|
||
);
|
||
|
||
const ctx = document.getElementById('lineChart').getContext('2d');
|
||
const chart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: values.map((_, i) => i + 1),
|
||
datasets: [{
|
||
label: firstNumeric,
|
||
data: values,
|
||
borderColor: 'rgba(16, 185, 129, 1)',
|
||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||
tension: 0.4,
|
||
fill: true
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: false
|
||
}
|
||
}
|
||
}
|
||
});
|
||
charts.push(chart);
|
||
}
|
||
}
|
||
|
||
function renderTable(data, columns) {
|
||
const table = document.getElementById('data-table');
|
||
const maxRows = Math.min(100, data.length);
|
||
|
||
let html = '<thead><tr>';
|
||
columns.forEach(col => {
|
||
html += '<th>' + escapeHtml(col) + '</th>';
|
||
});
|
||
html += '</tr></thead><tbody>';
|
||
|
||
for (let i = 0; i < maxRows; i++) {
|
||
html += '<tr>';
|
||
columns.forEach(col => {
|
||
html += '<td>' + escapeHtml(data[i][col]) + '</td>';
|
||
});
|
||
html += '</tr>';
|
||
}
|
||
|
||
if (data.length > maxRows) {
|
||
html += '<tr><td colspan="' + columns.length + '" style="text-align:center;color:#718096;">';
|
||
html += '... показано 100 из ' + data.length + ' записей ...';
|
||
html += '</td></tr>';
|
||
}
|
||
|
||
html += '</tbody>';
|
||
table.innerHTML = html;
|
||
}
|
||
|
||
function exportToCSV() {
|
||
if (!rawData || rawData.length === 0) return;
|
||
|
||
const ws = XLSX.utils.json_to_sheet(rawData);
|
||
const csv = XLSX.utils.sheet_to_csv(ws);
|
||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = fileName.replace(/\.[^/.]+$/, '') + '_analysis.csv';
|
||
link.click();
|
||
}
|
||
|
||
function exportToExcel() {
|
||
if (!rawData || rawData.length === 0) return;
|
||
|
||
const wb = XLSX.utils.book_new();
|
||
const ws = XLSX.utils.json_to_sheet(rawData);
|
||
XLSX.utils.book_append_sheet(wb, ws, 'Data');
|
||
XLSX.writeFile(wb, fileName.replace(/\.[^/.]+$/, '') + '_analysis.xlsx');
|
||
}
|
||
|
||
function showError(msg) {
|
||
errorMessage.textContent = msg;
|
||
errorMessage.classList.add('visible');
|
||
}
|
||
|
||
function hideError() {
|
||
errorMessage.classList.remove('visible');
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' Б';
|
||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' КБ';
|
||
return (bytes / 1048576).toFixed(1) + ' МБ';
|
||
}
|
||
|
||
function formatNumber(num) {
|
||
if (typeof num !== 'number') return num;
|
||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||
return Math.round(num * 100) / 100;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (text === null || text === undefined) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = String(text);
|
||
return div.innerHTML;
|
||
}
|