Files
test-mcp/js/analysis.js

450 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) 분석 엔진
*/
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;
document.getElementById('avg-system-info').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' };
} else if (soi < 30) {
return { label: '위험', class: 'badge-danger', key: 'danger' };
} else if (soi < 70) {
return { label: '주의', class: 'badge-warning', key: 'warning' };
} else {
return { label: '정상', class: 'badge-active', key: 'active' };
}
}
// Chart.js 시각화 엔진
function renderSOICharts(data) {
if (!data || data.length === 0) return;
// --- 1. 상태 분포 데이터 가공 (Doughnut Chart) ---
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,
plugins: {
legend: {
position: 'right',
labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true }
},
datalabels: { display: false }
},
cutout: '65%',
onClick: (event, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const keys = ['active', 'warning', 'danger', 'dead'];
const labels = ['정상', '주의', '위험', '사망'];
openProjectListModal(labels[index], stats[keys[index]]);
}
}
}
});
} catch (err) { console.error("도넛 차트 생성 실패:", err); }
// --- 2. 프로젝트 SWOT 매트릭스 진단 (Scatter Chart) ---
try {
const scatterData = data.map(p => ({
x: Math.min(500, p.file_count), // 최대 500으로 조정
y: p.p_war,
label: p.project_nm
}));
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
if (window.myVitalityChart) window.myVitalityChart.destroy();
const plugins = [];
if (typeof ChartDataLabels !== 'undefined') plugins.push(ChartDataLabels);
window.myVitalityChart = new Chart(vitalityCtx, {
type: 'scatter',
plugins: plugins,
data: {
datasets: [{
data: scatterData,
backgroundColor: (context) => {
const p = context.raw;
if (!p) return '#94a3b8';
if (p.x >= 250 && p.y >= 50) return '#1E5149'; // 핵심 우량 (기준 250)
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: (val) => val >= 500 ? '500+' : val.toLocaleString() }
},
y: {
min: 0,
max: 100,
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
grid: { display: false },
ticks: { stepSize: 25 }
}
},
plugins: {
legend: { display: false },
datalabels: {
align: 'top',
offset: 5,
font: { size: 10, weight: '700' },
color: '#475569',
formatter: (value) => value.label,
display: (context) => context.raw.x > 100 || context.raw.y < 30,
clip: false
},
tooltip: {
callbacks: {
label: (context) => ` [${context.raw.label}] SOI: ${context.raw.y.toFixed(1)}% | 파일: ${context.raw.x >= 500 ? '500+' : context.raw.x}`
}
}
}
},
plugins: [{
id: 'quadrants',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250); // 중앙축을 250으로 변경
const midY = y.getPixelForValue(50);
ctx.save();
// 1. 물리적으로 동일한 크기의 배경색 채우기
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); // 하우
// 2. 명확한 십자 구분선
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();
// 3. 영역 텍스트
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
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();
}
}]
});
} 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="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th>
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th>
<th style="position: sticky; top: 0; z-index: 10;">
현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button>
</th>
<th style="position: sticky; top: 0; z-index: 10;">
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 soi = p.p_war;
const pred = p.predicted_soi;
const rowId = `project-${idx}`;
let trendIcon = "";
if (pred !== null) {
const diff = pred - soi;
if (diff < -5) trendIcon = '<span style="color:#ef4444; font-size:10px;">▼ 급락</span>';
else if (diff < 0) trendIcon = '<span style="color:#f59e0b; font-size:10px;">↘ 하락</span>';
else trendIcon = '<span style="color:#22c55e; font-size:10px;">↗ 유지</span>';
}
// 수식 상세 데이터 준비
const baseLambda = 0.04;
const scaleImpact = Math.min(0.04, Math.log10(p.file_count + 1) * 0.008);
const envImpact = Math.max(0, p.ai_lambda - baseLambda - scaleImpact);
// 존재 신뢰도 패널티 (ECV)
let ecvText = "100% (신뢰)";
let ecvClass = "highlight-val";
if (p.file_count === 0) { ecvText = "5% (유령 프로젝트 패널티)"; ecvClass = "highlight-penalty"; }
else if (p.file_count < 10) { ecvText = "40% (소규모 껍데기 패널티)"; ecvClass = "highlight-penalty"; }
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 ${soi >= 70 ? 'text-plus' : 'text-minus'}">
${soi.toFixed(1)}%
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:700; font-size:14px; color:#6366f1;">
${pred !== null ? pred.toFixed(1) + '%' : '-'}
</span>
${trendIcon}
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="6">
<div class="detail-container">
<div class="formula-explanation-card">
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
AI 위험 적응형 모델(AAS) 산출 시뮬레이션
</div>
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div class="step-desc">기본 감쇄율에 자산 규모와 부서 위험도를 합산합니다.</div>
<div class="math-logic">
λ = ${baseLambda} (Base) + ${scaleImpact.toFixed(4)} (Scale) + ${envImpact.toFixed(4)} (Env)
= <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">방치 시간 감쇄 적용</div>
<div class="step-desc">마지막 로그 이후 경과된 시간만큼 가치를 하락시킵니다.</div>
<div class="math-logic">
AAS_Score = exp(-<span class="highlight-var">${p.ai_lambda.toFixed(4)}</span> × <span class="highlight-val">${p.days_stagnant}</span>) × 100
= <span class="highlight-val">${((soi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1)) || 0).toFixed(1)}%</span>
</div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 검증 (ECV Penalty)</div>
<div class="step-desc">파일 기반의 활동 신뢰도를 적용하여 유령 활동을 차단합니다.</div>
<div class="math-logic">
Final_SOI = AAS_Score × <span class="${ecvClass}">${ecvText}</span>
= <span style="color: #1e5149; font-size: 15px;">${soi.toFixed(1)}%</span>
</div>
</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) {
const isActive = detailRow.classList.contains('active');
if (!isActive) {
// 다른 열려있는 상세 행 닫기 (맥락 유지를 위해 권장)
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
detailRow.classList.add('active');
// 컨테이너 내부 스크롤 위치 계산
setTimeout(() => {
const headerHeight = container.querySelector('thead').offsetHeight || 40;
const rowTop = mainRow.offsetTop;
// 컨테이너를 정확한 위치로 스크롤 (행이 헤더 바로 밑에 오도록)
container.scrollTo({
top: rowTop - headerHeight,
behavior: 'smooth'
});
}, 50);
} else {
detailRow.classList.remove('active');
}
}
}
/**
* 차트 클릭 시 프로젝트 목록 모달 표시
*/
function openProjectListModal(statusLabel, projects) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
title.innerText = `[${statusLabel}] 상태 프로젝트 목록 (${projects.length}건)`;
if (projects.length === 0) {
body.innerHTML = '<p style="text-align:center; padding: 40px; color: #888;">해당 조건의 프로젝트가 없습니다.</p>';
} else {
body.innerHTML = `
<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.sort((a,b) => a.p_war - b.p_war).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">
<span class="formula-label">기본 산술 수식</span>
<div class="formula-box">SOI = exp(-0.05 × days) × 100</div>
</div>
<div class="desc-text">
<p>본 지수는 프로젝트의 <strong>'절대적 가치 보존율'</strong>을 측정합니다.</p>
<ul class="desc-list">
<li><strong>이상적 상태 (100%):</strong> 최근 24시간 이내 활동 로그가 발생한 경우입니다.</li>
<li><strong>지수 감쇄 모델:</strong> 방치일수가 늘어날수록 가치가 기하급수적으로 하락하도록 설계되었습니다. (14일 방치 시 약 50% 소실)</li>
<li><strong>시스템 사망:</strong> 지수가 10% 미만일 경우, 활동 재개 가능성이 희박한 좀비 데이터로 간주합니다.</li>
</ul>
</div>
`;
} else if (type === 'ai') {
title.innerText = 'AI 시계열 예측 알고리즘 상세';
body.innerHTML = `
<div class="formula-section">
<span class="formula-label">하이브리드 추세 엔진</span>
<div class="formula-box">Pred = (Linear × w1) + (Decay × w2)</div>
</div>
<div class="desc-text">
<p>딥러닝 엔진이 프로젝트의 <strong>'활동 가속도'</strong>를 분석하여 14일 뒤의 상태를 예보합니다.</p>
<ul class="desc-list">
<li><strong>추세 분석 (Linear):</strong> 최근 활동 로그의 빈도가 증가 추세일 경우, 향후 관리 재개 가능성을 높게 평가하여 가점을 부여합니다.</li>
<li><strong>자연 소멸 (Decay):</strong> 장기 정체 중인 프로젝트는 지수 감쇄 모델을 80% 이상 반영하여 급격한 하락을 경고합니다.</li>
<li><strong>정밀도:</strong> 현재 Regression 기반 모델이며, 데이터가 30회 이상 축적되면 LSTM 신경망으로 자동 전환됩니다.</li>
</ul>
</div>
`;
}
modal.style.display = 'flex';
}
function closeAnalysisModal(e) {
document.getElementById('analysisModal').style.display = 'none';
}