refactor: 분석 페이지 코드 정돈 및 AI 엔진 고도화 통합
This commit is contained in:
409
js/analysis.js
409
js/analysis.js
@@ -3,6 +3,11 @@
|
||||
* P-WAR (Project Performance Above Replacement) 분석 엔진
|
||||
*/
|
||||
|
||||
// Chart.js 플러그인 전역 등록
|
||||
if (typeof ChartDataLabels !== 'undefined') {
|
||||
Chart.register(ChartDataLabels);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log("Analysis engine initialized...");
|
||||
loadPWarData();
|
||||
@@ -12,43 +17,32 @@ 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%에 가까울수록 시스템 전반의 방치가 심각함)`;
|
||||
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' };
|
||||
} 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' };
|
||||
}
|
||||
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' };
|
||||
}
|
||||
|
||||
// Chart.js 시각화 엔진
|
||||
function renderSOICharts(data) {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// --- 1. 상태 분포 데이터 가공 (Doughnut Chart) ---
|
||||
// 1. 상태 분포 (Doughnut)
|
||||
try {
|
||||
const stats = { active: [], warning: [], danger: [], dead: [] };
|
||||
data.forEach(p => {
|
||||
@@ -72,30 +66,26 @@ function renderSOICharts(data) {
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: 15 },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true }
|
||||
},
|
||||
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
|
||||
datalabels: { display: false }
|
||||
},
|
||||
cutout: '65%',
|
||||
onClick: (event, elements) => {
|
||||
onClick: (e, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const index = elements[0].index;
|
||||
const keys = ['active', 'warning', 'danger', 'dead'];
|
||||
const labels = ['정상', '주의', '위험', '사망'];
|
||||
openProjectListModal(labels[index], stats[keys[index]]);
|
||||
const idx = elements[0].index;
|
||||
openProjectListModal(['정상', '주의', '위험', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) { console.error("도넛 차트 생성 실패:", err); }
|
||||
} catch (err) { console.error("도넛 차트 에러:", err); }
|
||||
|
||||
// --- 2. 프로젝트 SWOT 매트릭스 진단 (Scatter Chart) ---
|
||||
// 2. 프로젝트 SWOT 매트릭스 (Scatter)
|
||||
try {
|
||||
const scatterData = data.map(p => ({
|
||||
x: Math.min(500, p.file_count), // 최대 500으로 조정
|
||||
x: Math.min(500, p.file_count),
|
||||
y: p.p_war,
|
||||
label: p.project_nm
|
||||
}));
|
||||
@@ -103,22 +93,45 @@ function renderSOICharts(data) {
|
||||
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
|
||||
if (window.myVitalityChart) window.myVitalityChart.destroy();
|
||||
|
||||
const plugins = [];
|
||||
if (typeof ChartDataLabels !== 'undefined') plugins.push(ChartDataLabels);
|
||||
// 플러그인 통합 (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: plugins,
|
||||
plugins: chartPlugins,
|
||||
data: {
|
||||
datasets: [{
|
||||
data: scatterData,
|
||||
backgroundColor: (context) => {
|
||||
const p = context.raw;
|
||||
backgroundColor: (ctx) => {
|
||||
const p = ctx.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'; // 관리 사각지대
|
||||
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
|
||||
@@ -130,80 +143,37 @@ function renderSOICharts(data) {
|
||||
layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: 500, // 데이터 분포에 최적화
|
||||
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() }
|
||||
ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v }
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
min: 0, max: 100,
|
||||
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false },
|
||||
ticks: { stepSize: 25 }
|
||||
grid: { display: false }
|
||||
}
|
||||
},
|
||||
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,
|
||||
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: (context) => ` [${context.raw.label}] SOI: ${context.raw.y.toFixed(1)}% | 파일: ${context.raw.x >= 500 ? '500+' : context.raw.x}개`
|
||||
}
|
||||
callbacks: { label: (ctx) => ` [${ctx.raw.label}] SOI: ${ctx.raw.y.toFixed(1)}% | 파일: ${ctx.raw.x >= 500 ? '500+' : ctx.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); }
|
||||
|
||||
|
||||
} 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 = `
|
||||
@@ -211,239 +181,136 @@ function renderPWarLeaderboard(data) {
|
||||
<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>
|
||||
<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 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"; }
|
||||
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}')">
|
||||
<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 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; 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 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="6">
|
||||
<td colspan="7">
|
||||
<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 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-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 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="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 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>
|
||||
`;
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
</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) {
|
||||
// 다른 열려있는 상세 행 닫기 (맥락 유지를 위해 권장)
|
||||
if (!detailRow.classList.contains('active')) {
|
||||
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');
|
||||
}
|
||||
setTimeout(() => { container.scrollTo({ top: mainRow.offsetTop - (container.querySelector('thead').offsetHeight || 40), behavior: 'smooth' }); }, 50);
|
||||
} else detailRow.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 클릭 시 프로젝트 목록 모달 표시
|
||||
*/
|
||||
function openProjectListModal(statusLabel, projects) {
|
||||
function openProjectListModal(label, 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>
|
||||
`;
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
`;
|
||||
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(e) {
|
||||
document.getElementById('analysisModal').style.display = 'none';
|
||||
}
|
||||
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
|
||||
|
||||
Reference in New Issue
Block a user