450 lines
22 KiB
JavaScript
450 lines
22 KiB
JavaScript
/**
|
||
* 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';
|
||
}
|