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