Files
test-mcp/js/analysis.js

317 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Project Master Analysis JS
* P-WAR (Project Performance Above Replacement) 분석 엔진
*/
// Chart.js 플러그인 전역 등록
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
}
document.addEventListener('DOMContentLoaded', () => {
console.log("Analysis engine initialized...");
loadPWarData();
});
async function loadPWarData() {
try {
const response = await fetch('/api/analysis/p-war');
const data = await response.json();
if (data.error) throw new Error(data.error);
renderPWarLeaderboard(data);
renderSOICharts(data);
if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info;
const infoEl = document.getElementById('avg-system-info');
if (infoEl) infoEl.textContent = `* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 방치 심각)`;
}
} catch (e) {
console.error("분석 데이터 로딩 실패:", e);
}
}
function getStatusInfo(soi, isAutoDelete) {
if (isAutoDelete || soi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
if (soi < 30) return { label: '위험', class: 'badge-danger', key: 'danger' };
if (soi < 70) return { label: '주의', class: 'badge-warning', key: 'warning' };
return { label: '정상', class: 'badge-active', key: 'active' };
}
function renderSOICharts(data) {
if (!data || data.length === 0) return;
// 1. 상태 분포 (Doughnut)
try {
const stats = { active: [], warning: [], danger: [], dead: [] };
data.forEach(p => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
stats[status.key].push(p);
});
const statusCtx = document.getElementById('statusChart').getContext('2d');
if (window.myStatusChart) window.myStatusChart.destroy();
window.myStatusChart = new Chart(statusCtx, {
type: 'doughnut',
data: {
labels: ['정상', '주의', '위험', '사망'],
datasets: [{
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: 15 },
plugins: {
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
datalabels: { display: false }
},
cutout: '65%',
onClick: (e, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
openProjectListModal(['정상', '주의', '위험', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
}
}
}
});
} catch (err) { console.error("도넛 차트 에러:", err); }
// 2. 프로젝트 SWOT 매트릭스 (Scatter)
try {
const scatterData = data.map(p => ({
x: Math.min(500, p.file_count),
y: p.p_war,
label: p.project_nm
}));
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
if (window.myVitalityChart) window.myVitalityChart.destroy();
// 플러그인 통합 (Duplicate Key 방지)
const chartPlugins = [];
if (typeof ChartDataLabels !== 'undefined') chartPlugins.push(ChartDataLabels);
chartPlugins.push({
id: 'quadrants',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250);
const midY = y.getPixelForValue(50);
ctx.save();
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillText('활동 양호', (left + midX) / 2, (top + midY) / 2);
ctx.fillText('핵심 우량', (midX + right) / 2, (top + midY) / 2);
ctx.fillText('방치/소규모', (left + midX) / 2, (midY + bottom) / 2);
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('관리 사각지대', (midX + right) / 2, (midY + bottom) / 2);
ctx.restore();
}
});
window.myVitalityChart = new Chart(vitalityCtx, {
type: 'scatter',
plugins: chartPlugins,
data: {
datasets: [{
data: scatterData,
backgroundColor: (ctx) => {
const p = ctx.raw;
if (!p) return '#94a3b8';
if (p.x >= 250 && p.y >= 50) return '#1E5149';
if (p.x < 250 && p.y >= 50) return '#22c55e';
if (p.x < 250 && p.y < 50) return '#94a3b8';
return '#ef4444';
},
pointRadius: 6,
hoverRadius: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } },
scales: {
x: {
type: 'linear', min: 0, max: 500,
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
grid: { display: false },
ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v }
},
y: {
min: 0, max: 100,
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
grid: { display: false }
}
},
plugins: {
legend: { display: false },
datalabels: {
align: 'top', offset: 5, font: { size: 10, weight: '700' }, color: '#475569',
formatter: (v) => v.label,
display: (ctx) => ctx.raw.x > 100 || ctx.raw.y < 30,
clip: false
},
tooltip: {
callbacks: { label: (ctx) => ` [${ctx.raw.label}] SOI: ${ctx.raw.y.toFixed(1)}% | 파일: ${ctx.raw.x >= 500 ? '500+' : ctx.raw.x}` }
}
}
}
});
} catch (err) { console.error("SWOT 차트 에러:", err); }
}
function renderPWarLeaderboard(data) {
const container = document.getElementById('p-war-table-container');
if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
container.innerHTML = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="width: 280px;">프로젝트명</th>
<th>파일 수</th>
<th>방치일</th>
<th>상태 판정</th>
<th>현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button></th>
<th style="text-align:center;">실무 투입</th>
<th>AI 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</button></th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const rowId = `project-${idx}`;
const ecvText = p.file_count === 0 ? "5% (유령)" : p.file_count < 10 ? "40% (껍데기)" : "100% (신뢰)";
const ecvClass = (p.file_count < 10) ? "highlight-penalty" : "highlight-val";
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}개</td>
<td>${p.days_stagnant}일</td>
<td><span class="${status.class}">${status.label}</span></td>
<td class="p-war-value ${p.p_war >= 70 ? 'text-plus' : 'text-minus'}">${p.p_war.toFixed(1)}%</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div>
</div>
</td>
<td style="text-align:center; font-weight:700; color:#6366f1;">${p.predicted_soi !== null ? p.predicted_soi.toFixed(1) + '%' : '-'}</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="7">
<div class="detail-container">
<div class="formula-explanation-card">
<div class="formula-header">⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션</div>
<div class="work-effort-section">
<div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 활성화 분석 (Work Vitality)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">투입률 ${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div></div>
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">최근 30회 중 실제 파일 변동이 포착된 날의 비율입니다. 현재 <b>${p.work_effort >= 70 ? '매우 활발' : p.work_effort <= 30 ? '정체' : '간헐적'}</b> 상태입니다.</div>
</div>
<div class="formula-steps-grid">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ)</div>
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">활동 품질 (Quality)</div>
<div class="math-logic">Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄</div>
<div class="math-logic">Result = <span class="highlight-val">${((p.p_war / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div class="final-result-area">
<div style="font-size: 11px; color: #64748b;">* 공식: AAS_Score × Quality_Factor × ECV_Factor</div>
<div><span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 P-SOI: </span><span style="color: #1e5149; font-size: 22px; font-weight: 900;">${p.p_war.toFixed(1)}%</span></div>
</div>
</div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
function toggleProjectDetail(rowId) {
const container = document.querySelector('.table-scroll-wrapper');
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
const detailRow = document.getElementById(`detail-${rowId}`);
if (detailRow && container) {
if (!detailRow.classList.contains('active')) {
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
detailRow.classList.add('active');
setTimeout(() => { container.scrollTo({ top: mainRow.offsetTop - (container.querySelector('thead').offsetHeight || 40), behavior: 'smooth' }); }, 50);
} else detailRow.classList.remove('active');
}
}
function openProjectListModal(label, projects) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
title.innerText = `[${label}] 프로젝트 목록 (${projects.length}건)`;
body.innerHTML = projects.length === 0 ? '<p style="text-align:center; padding: 40px; color: #888;">데이터 없음</p>' : `
<div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table">
<thead><tr><th>프로젝트명</th><th>관리자</th><th>방치일</th><th>현재 SOI</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table>
</div>`;
modal.style.display = 'flex';
}
function openAnalysisModal(type) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
if (type === 'soi') {
title.innerText = 'P-SOI 산출 공식 상세';
body.innerHTML = '<div class="formula-section"><div class="formula-box">SOI = exp(-λ × days) × 100</div></div><p>방치일수에 따른 가치 하락 모델입니다.</p>';
} else {
title.innerText = 'AI 시계열 예측 상세';
body.innerHTML = '<p>활동 가속도 및 밀도를 분석하여 14일 뒤의 상태를 예보합니다.</p>';
}
modal.style.display = 'flex';
}
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }