refactor: 분석 페이지 코드 정돈 및 AI 엔진 고도화 통합
This commit is contained in:
49
ANALYSIS_REPORT.md
Normal file
49
ANALYSIS_REPORT.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# 📊 Project Master Sabermetrics 분석 엔진 리포트
|
||||||
|
|
||||||
|
## 1. 개요 (Vision)
|
||||||
|
본 시스템은 방대한 프로젝트 운영 데이터(파일 수, 활동 로그, 조직 정보)를 기반으로 **AI 기반 프로젝트 건강도(P-SOI)**를 산출합니다. 단순히 "살아있는가"를 넘어, "실무적으로 가치 있게 관리되고 있는가"를 정밀 진단하는 것이 목적입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. P-SOI 산출 로직 (The Formula)
|
||||||
|
|
||||||
|
### 2.1 기초 모델: 지수 감쇄 (Exponential Decay)
|
||||||
|
프로젝트 정보의 가치는 관리 활동이 멈춘 시점부터 시간이 흐를수록 급격히 하락합니다.
|
||||||
|
- **수식**: $SOI = 100 \times e^{-\lambda t}$
|
||||||
|
- **의미**: 14일 방치 시 가치가 약 50% 소실되는 현장 현실을 반영합니다.
|
||||||
|
|
||||||
|
### 2.2 고도화 1: AAS (AI-Hazard Adaptive SOI)
|
||||||
|
프로젝트의 중요도와 주변 환경에 따라 하락 곡선의 기울기를 동적으로 조정합니다.
|
||||||
|
- **자산 규모 영향**: 파일 수가 많을수록 관리 부재 리스크가 크므로 AI가 하락 속도를 가속시킵니다.
|
||||||
|
- **조직 위험 전염**: 소속 부서나 담당자의 전체 SOI가 낮을 경우, 시스템적 붕괴 리스크 가중치를 부여합니다.
|
||||||
|
|
||||||
|
### 2.3 고도화 2: ECV (Existence-Conditioned Vitality)
|
||||||
|
'빈 껍데기' 활동을 걸러내는 존재론적 패널티입니다.
|
||||||
|
- **유령 프로젝트**: 파일 수가 0개인 경우, 최근 로그와 관계없이 SOI 점수를 **5% 미만**으로 강제 고정합니다.
|
||||||
|
- **신뢰 보정**: 파일 10개 미만의 소규모 프로젝트는 활동 신뢰도를 40% 수준으로 제한합니다.
|
||||||
|
|
||||||
|
### 2.4 고도화 3: 로그 품질 및 실무 투입 분석
|
||||||
|
- **Log Quality**: 로그 텍스트를 분석하여 [실무 활동(1.0), 관리 활동(0.7), 행정 활동(0.4)] 가중치를 부여합니다.
|
||||||
|
- **Work Effort**: 최근 30개 히스토리 중 실제 **파일 증감**이 발생한 날의 비율을 계산하여 실질 공수 투입률을 산출합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 전략적 분석 도구 (Visualization)
|
||||||
|
|
||||||
|
### 3.1 프로젝트 SWOT 매트릭스
|
||||||
|
X축(자산 규모)과 Y축(활동성)을 결합하여 4가지 국면으로 프로젝트를 진단합니다.
|
||||||
|
1. **핵심 우량 (Strategic)**: 대규모 핵심 자산이며 활발히 관리 중.
|
||||||
|
2. **활동 양호 (Agile)**: 규모는 작으나 매우 탄력적으로 업데이트 중.
|
||||||
|
3. **방치/소규모**: 중요도가 낮고 방치된 상태.
|
||||||
|
4. **관리 사각지대 (Critical Risk)**: **자산 규모는 크나 장기 방치됨 (최우선 관리 대상)**
|
||||||
|
|
||||||
|
### 3.2 AI 진단 아코디언 리포트
|
||||||
|
사용자가 지수 산출 결과에 납득할 수 있도록, 개별 프로젝트 행 클릭 시 **4단계 AI 추론 과정**을 실시간 리포트로 제공합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 향후 딥러닝 로드맵 (Evolution)
|
||||||
|
데이터가 누적됨에 따라 다음과 같은 자가 학습형 엔진으로 진화합니다.
|
||||||
|
- **LSTM 기반 리듬 학습**: 각 프로젝트의 고유한 업데이트 주기와 패턴(Life Rhythm)을 인코딩하여 맞춤형 예보 수행.
|
||||||
|
- **NLP 임베딩**: 단순 키워드를 넘어 로그 텍스트의 맥락적 의미를 딥러닝이 스스로 학습하여 가중치 자동 산정.
|
||||||
|
- **병목 예측 AI**: 특정 담당자나 부서의 업무 과부하 패턴을 학습하여 집단 방치 위험을 선제적으로 예보.
|
||||||
BIN
__pycache__/analysis_service.cpython-312.pyc
Normal file
BIN
__pycache__/analysis_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/inquiry_service.cpython-312.pyc
Normal file
BIN
__pycache__/inquiry_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/prediction_service.cpython-312.pyc
Normal file
BIN
__pycache__/prediction_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/project_service.cpython-312.pyc
Normal file
BIN
__pycache__/project_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/schemas.cpython-312.pyc
Normal file
BIN
__pycache__/schemas.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -127,22 +127,46 @@ class AnalysisService:
|
|||||||
# 지수 감쇄 적용 (AAS Score)
|
# 지수 감쇄 적용 (AAS Score)
|
||||||
soi_score = math.exp(-ai_lambda * days_stagnant) * 100
|
soi_score = math.exp(-ai_lambda * days_stagnant) * 100
|
||||||
|
|
||||||
# [AI 데이터 진정성 검증 로직 - ECV 패널티 추가]
|
# [AI 데이터 진정성 검증 로직 1 - ECV 패널티 (존재론적)]
|
||||||
# 파일이 하나도 없거나(유령), 현저히 적은 경우(껍데기) 활동의 진정성을 불신함
|
|
||||||
existence_confidence = 1.0
|
existence_confidence = 1.0
|
||||||
if file_count == 0:
|
if file_count == 0:
|
||||||
existence_confidence = 0.05 # 파일 0개는 로그가 있어도 최대 5% 미만으로 강제
|
existence_confidence = 0.05
|
||||||
elif file_count < 10:
|
elif file_count < 10:
|
||||||
existence_confidence = 0.4 # 파일 10개 미만은 활동 신뢰도 40%로 제한
|
existence_confidence = 0.4
|
||||||
|
|
||||||
soi_score = soi_score * existence_confidence
|
# [AI 데이터 진정성 검증 로직 2 - Log Quality Scoring (활동의 질)]
|
||||||
|
log_quality_factor = 1.0
|
||||||
|
if log and log != "데이터 없음":
|
||||||
|
# 성과 중심 (High)
|
||||||
|
if any(k in log for k in ["업로드", "수정", "등록", "변환", "파일", "업데이트"]):
|
||||||
|
log_quality_factor = 1.0
|
||||||
|
# 구조 관리 (Mid)
|
||||||
|
elif any(k in log for k in ["폴더", "생성", "삭제", "이동"]):
|
||||||
|
log_quality_factor = 0.7
|
||||||
|
# 단순 행정/설정 (Low)
|
||||||
|
elif any(k in log for k in ["참가자", "권한", "추가", "변경", "메일"]):
|
||||||
|
log_quality_factor = 0.4
|
||||||
|
else:
|
||||||
|
log_quality_factor = 0.6 # 기타 일반 로그
|
||||||
|
|
||||||
|
# 최종 점수 산출 (AAS * ECV * LogQuality)
|
||||||
|
soi_score = soi_score * existence_confidence * log_quality_factor
|
||||||
|
|
||||||
if is_auto_delete:
|
if is_auto_delete:
|
||||||
soi_score = 0.1
|
soi_score = 0.1
|
||||||
|
|
||||||
# [AI 미래 예측 연동]
|
# [AI 미래 예측 및 실무 투입 에너지 분석]
|
||||||
history = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
|
history_rows = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
|
||||||
predicted_soi = SOIPredictionService.predict_future_soi(history, days_ahead=14)
|
predicted_soi = SOIPredictionService.predict_future_soi(soi_score, history_rows, days_ahead=14)
|
||||||
|
|
||||||
|
# 실무 투입 에너지 계산 (최근 30개 히스토리 기준 파일 변화일수)
|
||||||
|
effort_days = 0
|
||||||
|
if len(history_rows) > 1:
|
||||||
|
for i in range(1, len(history_rows)):
|
||||||
|
if history_rows[i]['file_count'] != history_rows[i-1]['file_count']:
|
||||||
|
effort_days += 1
|
||||||
|
|
||||||
|
work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1)
|
||||||
|
|
||||||
total_soi += soi_score
|
total_soi += soi_score
|
||||||
|
|
||||||
@@ -156,7 +180,9 @@ class AnalysisService:
|
|||||||
"is_auto_delete": is_auto_delete,
|
"is_auto_delete": is_auto_delete,
|
||||||
"master": p['master'],
|
"master": p['master'],
|
||||||
"dept": p['department'],
|
"dept": p['department'],
|
||||||
"ai_lambda": round(ai_lambda, 4), # 디버깅용 계수 포함
|
"ai_lambda": round(ai_lambda, 4),
|
||||||
|
"log_quality": log_quality_factor,
|
||||||
|
"work_effort": work_effort_rate, # 신규 지표 추가
|
||||||
"avg_info": {
|
"avg_info": {
|
||||||
"avg_files": 0,
|
"avg_files": 0,
|
||||||
"avg_stagnant": 0,
|
"avg_stagnant": 0,
|
||||||
|
|||||||
409
js/analysis.js
409
js/analysis.js
@@ -3,6 +3,11 @@
|
|||||||
* P-WAR (Project Performance Above Replacement) 분석 엔진
|
* P-WAR (Project Performance Above Replacement) 분석 엔진
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Chart.js 플러그인 전역 등록
|
||||||
|
if (typeof ChartDataLabels !== 'undefined') {
|
||||||
|
Chart.register(ChartDataLabels);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
console.log("Analysis engine initialized...");
|
console.log("Analysis engine initialized...");
|
||||||
loadPWarData();
|
loadPWarData();
|
||||||
@@ -12,43 +17,32 @@ async function loadPWarData() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/analysis/p-war');
|
const response = await fetch('/api/analysis/p-war');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
// 업데이트 로직: 리더보드 및 차트 렌더링
|
|
||||||
renderPWarLeaderboard(data);
|
renderPWarLeaderboard(data);
|
||||||
renderSOICharts(data);
|
renderSOICharts(data);
|
||||||
|
|
||||||
// 시스템 정보 표시
|
|
||||||
if (data.length > 0 && data[0].avg_info) {
|
if (data.length > 0 && data[0].avg_info) {
|
||||||
const avg = data[0].avg_info;
|
const avg = data[0].avg_info;
|
||||||
document.getElementById('avg-system-info').textContent =
|
const infoEl = document.getElementById('avg-system-info');
|
||||||
`* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 시스템 전반의 방치가 심각함)`;
|
if (infoEl) infoEl.textContent = `* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 방치 심각)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("분석 데이터 로딩 실패:", e);
|
console.error("분석 데이터 로딩 실패:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 판정 공통 함수
|
|
||||||
function getStatusInfo(soi, isAutoDelete) {
|
function getStatusInfo(soi, isAutoDelete) {
|
||||||
if (isAutoDelete || soi < 10) {
|
if (isAutoDelete || soi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
|
||||||
return { label: '사망', class: 'badge-system', key: 'dead' };
|
if (soi < 30) return { label: '위험', class: 'badge-danger', key: 'danger' };
|
||||||
} else if (soi < 30) {
|
if (soi < 70) return { label: '주의', class: 'badge-warning', key: 'warning' };
|
||||||
return { label: '위험', class: 'badge-danger', key: 'danger' };
|
return { label: '정상', class: 'badge-active', key: 'active' };
|
||||||
} else if (soi < 70) {
|
|
||||||
return { label: '주의', class: 'badge-warning', key: 'warning' };
|
|
||||||
} else {
|
|
||||||
return { label: '정상', class: 'badge-active', key: 'active' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart.js 시각화 엔진
|
|
||||||
function renderSOICharts(data) {
|
function renderSOICharts(data) {
|
||||||
if (!data || data.length === 0) return;
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
// --- 1. 상태 분포 데이터 가공 (Doughnut Chart) ---
|
// 1. 상태 분포 (Doughnut)
|
||||||
try {
|
try {
|
||||||
const stats = { active: [], warning: [], danger: [], dead: [] };
|
const stats = { active: [], warning: [], danger: [], dead: [] };
|
||||||
data.forEach(p => {
|
data.forEach(p => {
|
||||||
@@ -72,30 +66,26 @@ function renderSOICharts(data) {
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
layout: { padding: 15 },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
|
||||||
position: 'right',
|
|
||||||
labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true }
|
|
||||||
},
|
|
||||||
datalabels: { display: false }
|
datalabels: { display: false }
|
||||||
},
|
},
|
||||||
cutout: '65%',
|
cutout: '65%',
|
||||||
onClick: (event, elements) => {
|
onClick: (e, elements) => {
|
||||||
if (elements.length > 0) {
|
if (elements.length > 0) {
|
||||||
const index = elements[0].index;
|
const idx = elements[0].index;
|
||||||
const keys = ['active', 'warning', 'danger', 'dead'];
|
openProjectListModal(['정상', '주의', '위험', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
|
||||||
const labels = ['정상', '주의', '위험', '사망'];
|
|
||||||
openProjectListModal(labels[index], stats[keys[index]]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) { console.error("도넛 차트 생성 실패:", err); }
|
} catch (err) { console.error("도넛 차트 에러:", err); }
|
||||||
|
|
||||||
// --- 2. 프로젝트 SWOT 매트릭스 진단 (Scatter Chart) ---
|
// 2. 프로젝트 SWOT 매트릭스 (Scatter)
|
||||||
try {
|
try {
|
||||||
const scatterData = data.map(p => ({
|
const scatterData = data.map(p => ({
|
||||||
x: Math.min(500, p.file_count), // 최대 500으로 조정
|
x: Math.min(500, p.file_count),
|
||||||
y: p.p_war,
|
y: p.p_war,
|
||||||
label: p.project_nm
|
label: p.project_nm
|
||||||
}));
|
}));
|
||||||
@@ -103,22 +93,45 @@ function renderSOICharts(data) {
|
|||||||
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
|
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
|
||||||
if (window.myVitalityChart) window.myVitalityChart.destroy();
|
if (window.myVitalityChart) window.myVitalityChart.destroy();
|
||||||
|
|
||||||
const plugins = [];
|
// 플러그인 통합 (Duplicate Key 방지)
|
||||||
if (typeof ChartDataLabels !== 'undefined') plugins.push(ChartDataLabels);
|
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, {
|
window.myVitalityChart = new Chart(vitalityCtx, {
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
plugins: plugins,
|
plugins: chartPlugins,
|
||||||
data: {
|
data: {
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: scatterData,
|
data: scatterData,
|
||||||
backgroundColor: (context) => {
|
backgroundColor: (ctx) => {
|
||||||
const p = context.raw;
|
const p = ctx.raw;
|
||||||
if (!p) return '#94a3b8';
|
if (!p) return '#94a3b8';
|
||||||
if (p.x >= 250 && p.y >= 50) return '#1E5149'; // 핵심 우량 (기준 250)
|
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 '#22c55e';
|
||||||
if (p.x < 250 && p.y < 50) return '#94a3b8'; // 방치/소규모
|
if (p.x < 250 && p.y < 50) return '#94a3b8';
|
||||||
return '#ef4444'; // 관리 사각지대
|
return '#ef4444';
|
||||||
},
|
},
|
||||||
pointRadius: 6,
|
pointRadius: 6,
|
||||||
hoverRadius: 10
|
hoverRadius: 10
|
||||||
@@ -130,80 +143,37 @@ function renderSOICharts(data) {
|
|||||||
layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } },
|
layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } },
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
type: 'linear',
|
type: 'linear', min: 0, max: 500,
|
||||||
min: 0,
|
|
||||||
max: 500, // 데이터 분포에 최적화
|
|
||||||
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
|
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
ticks: { stepSize: 125, callback: (val) => val >= 500 ? '500+' : val.toLocaleString() }
|
ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v }
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
min: 0,
|
min: 0, max: 100,
|
||||||
max: 100,
|
|
||||||
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
|
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
|
||||||
grid: { display: false },
|
grid: { display: false }
|
||||||
ticks: { stepSize: 25 }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
datalabels: {
|
datalabels: {
|
||||||
align: 'top',
|
align: 'top', offset: 5, font: { size: 10, weight: '700' }, color: '#475569',
|
||||||
offset: 5,
|
formatter: (v) => v.label,
|
||||||
font: { size: 10, weight: '700' },
|
display: (ctx) => ctx.raw.x > 100 || ctx.raw.y < 30,
|
||||||
color: '#475569',
|
|
||||||
formatter: (value) => value.label,
|
|
||||||
display: (context) => context.raw.x > 100 || context.raw.y < 30,
|
|
||||||
clip: false
|
clip: false
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: { label: (ctx) => ` [${ctx.raw.label}] SOI: ${ctx.raw.y.toFixed(1)}% | 파일: ${ctx.raw.x >= 500 ? '500+' : ctx.raw.x}개` }
|
||||||
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); }
|
} catch (err) { console.error("SWOT 차트 에러:", err); }
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPWarLeaderboard(data) {
|
function renderPWarLeaderboard(data) {
|
||||||
const container = document.getElementById('p-war-table-container');
|
const container = document.getElementById('p-war-table-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
|
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
@@ -211,239 +181,136 @@ function renderPWarLeaderboard(data) {
|
|||||||
<table class="data-table p-war-table">
|
<table class="data-table p-war-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th>
|
<th style="width: 280px;">프로젝트명</th>
|
||||||
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
|
<th>파일 수</th>
|
||||||
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
|
<th>방치일</th>
|
||||||
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th>
|
<th>상태 판정</th>
|
||||||
<th style="position: sticky; top: 0; z-index: 10;">
|
<th>현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button></th>
|
||||||
현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button>
|
<th style="text-align:center;">실무 투입</th>
|
||||||
</th>
|
<th>AI 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${sortedData.map((p, idx) => {
|
${sortedData.map((p, idx) => {
|
||||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||||
const soi = p.p_war;
|
|
||||||
const pred = p.predicted_soi;
|
|
||||||
const rowId = `project-${idx}`;
|
const rowId = `project-${idx}`;
|
||||||
|
const ecvText = p.file_count === 0 ? "5% (유령)" : p.file_count < 10 ? "40% (껍데기)" : "100% (신뢰)";
|
||||||
let trendIcon = "";
|
const ecvClass = (p.file_count < 10) ? "highlight-penalty" : "highlight-val";
|
||||||
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 `
|
return `
|
||||||
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}"
|
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
|
||||||
onclick="toggleProjectDetail('${rowId}')">
|
|
||||||
<td class="font-bold">${p.project_nm}</td>
|
<td class="font-bold">${p.project_nm}</td>
|
||||||
<td>${p.file_count.toLocaleString()}개</td>
|
<td>${p.file_count.toLocaleString()}개</td>
|
||||||
<td>${p.days_stagnant}일</td>
|
<td>${p.days_stagnant}일</td>
|
||||||
<td><span class="${status.class}">${status.label}</span></td>
|
<td><span class="${status.class}">${status.label}</span></td>
|
||||||
<td class="p-war-value ${soi >= 70 ? 'text-plus' : 'text-minus'}">
|
<td class="p-war-value ${p.p_war >= 70 ? 'text-plus' : 'text-minus'}">${p.p_war.toFixed(1)}%</td>
|
||||||
${soi.toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td style="text-align:center;">
|
<td style="text-align:center;">
|
||||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
|
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||||
<span style="font-weight:700; font-size:14px; color:#6366f1;">
|
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
|
||||||
${pred !== null ? pred.toFixed(1) + '%' : '-'}
|
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
|
||||||
</span>
|
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
|
||||||
${trendIcon}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td style="text-align:center; font-weight:700; color:#6366f1;">${p.predicted_soi !== null ? p.predicted_soi.toFixed(1) + '%' : '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="detail-${rowId}" class="detail-row">
|
<tr id="detail-${rowId}" class="detail-row">
|
||||||
<td colspan="6">
|
<td colspan="7">
|
||||||
<div class="detail-container">
|
<div class="detail-container">
|
||||||
<div class="formula-explanation-card">
|
<div class="formula-explanation-card">
|
||||||
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
|
<div class="formula-header">⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션</div>
|
||||||
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
|
<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>
|
||||||
|
<div class="formula-steps-grid">
|
||||||
<div class="formula-step">
|
<div class="formula-step">
|
||||||
<div class="step-num">1</div>
|
<div class="step-num">1</div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<div class="step-title">동적 위험 계수(λ) 산출</div>
|
<div class="step-title">동적 위험 계수(λ)</div>
|
||||||
<div class="step-desc">기본 감쇄율에 자산 규모와 부서 위험도를 합산합니다.</div>
|
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
|
||||||
<div class="math-logic">
|
</div>
|
||||||
λ = ${baseLambda} (Base) + ${scaleImpact.toFixed(4)} (Scale) + ${envImpact.toFixed(4)} (Env)
|
</div>
|
||||||
= <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="final-result-area">
|
||||||
<div class="formula-step">
|
<div style="font-size: 11px; color: #64748b;">* 공식: AAS_Score × Quality_Factor × ECV_Factor</div>
|
||||||
<div class="step-num">2</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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`;
|
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>`;
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 테이블 행 클릭 시 상세 아코디언 토글 및 스크롤 제어
|
|
||||||
*/
|
|
||||||
function toggleProjectDetail(rowId) {
|
function toggleProjectDetail(rowId) {
|
||||||
const container = document.querySelector('.table-scroll-wrapper');
|
const container = document.querySelector('.table-scroll-wrapper');
|
||||||
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
|
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
|
||||||
const detailRow = document.getElementById(`detail-${rowId}`);
|
const detailRow = document.getElementById(`detail-${rowId}`);
|
||||||
|
|
||||||
if (detailRow && container) {
|
if (detailRow && container) {
|
||||||
const isActive = detailRow.classList.contains('active');
|
if (!detailRow.classList.contains('active')) {
|
||||||
|
|
||||||
if (!isActive) {
|
|
||||||
// 다른 열려있는 상세 행 닫기 (맥락 유지를 위해 권장)
|
|
||||||
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
|
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
|
||||||
|
|
||||||
detailRow.classList.add('active');
|
detailRow.classList.add('active');
|
||||||
|
setTimeout(() => { container.scrollTo({ top: mainRow.offsetTop - (container.querySelector('thead').offsetHeight || 40), behavior: 'smooth' }); }, 50);
|
||||||
// 컨테이너 내부 스크롤 위치 계산
|
} else detailRow.classList.remove('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(label, projects) {
|
||||||
* 차트 클릭 시 프로젝트 목록 모달 표시
|
|
||||||
*/
|
|
||||||
function openProjectListModal(statusLabel, projects) {
|
|
||||||
const modal = document.getElementById('analysisModal');
|
const modal = document.getElementById('analysisModal');
|
||||||
const title = document.getElementById('modalTitle');
|
const title = document.getElementById('modalTitle');
|
||||||
const body = document.getElementById('modalBody');
|
const body = document.getElementById('modalBody');
|
||||||
|
title.innerText = `[${label}] 프로젝트 목록 (${projects.length}건)`;
|
||||||
title.innerText = `[${statusLabel}] 상태 프로젝트 목록 (${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;">
|
||||||
if (projects.length === 0) {
|
<table class="data-table">
|
||||||
body.innerHTML = '<p style="text-align:center; padding: 40px; color: #888;">해당 조건의 프로젝트가 없습니다.</p>';
|
<thead><tr><th>프로젝트명</th><th>관리자</th><th>방치일</th><th>현재 SOI</th></tr></thead>
|
||||||
} else {
|
<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>
|
||||||
body.innerHTML = `
|
</table>
|
||||||
<div class="table-scroll-wrapper" style="max-height: 400px;">
|
</div>`;
|
||||||
<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';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 분석 상세 설명 모달 제어
|
|
||||||
*/
|
|
||||||
function openAnalysisModal(type) {
|
function openAnalysisModal(type) {
|
||||||
const modal = document.getElementById('analysisModal');
|
const modal = document.getElementById('analysisModal');
|
||||||
const title = document.getElementById('modalTitle');
|
const title = document.getElementById('modalTitle');
|
||||||
const body = document.getElementById('modalBody');
|
const body = document.getElementById('modalBody');
|
||||||
|
|
||||||
if (type === 'soi') {
|
if (type === 'soi') {
|
||||||
title.innerText = 'P-SOI (관리 지수) 산출 공식 상세';
|
title.innerText = 'P-SOI 산출 공식 상세';
|
||||||
body.innerHTML = `
|
body.innerHTML = '<div class="formula-section"><div class="formula-box">SOI = exp(-λ × days) × 100</div></div><p>방치일수에 따른 가치 하락 모델입니다.</p>';
|
||||||
<div class="formula-section">
|
} else {
|
||||||
<span class="formula-label">기본 산술 수식</span>
|
title.innerText = 'AI 시계열 예측 상세';
|
||||||
<div class="formula-box">SOI = exp(-0.05 × days) × 100</div>
|
body.innerHTML = '<p>활동 가속도 및 밀도를 분석하여 14일 뒤의 상태를 예보합니다.</p>';
|
||||||
</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';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAnalysisModal(e) {
|
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
|
||||||
document.getElementById('analysisModal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|||||||
160
js/analysis.js_fragment_leaderboard
Normal file
160
js/analysis.js_fragment_leaderboard
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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; text-align:center;">실무 투입</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; 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'}; transition: width 0.5s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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="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>
|
||||||
|
|
||||||
|
<!-- 실무 투입 분석 (상단 배치) -->
|
||||||
|
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<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 style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
|
||||||
|
<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>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
|
||||||
|
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 수식 단계 2x2 그리드 (1-4, 2-3 순서) -->
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||||
|
<!-- Row 1: Step 1 & Step 4 -->
|
||||||
|
<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">4</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<div class="step-title">활동 품질 검증 (Quality)</div>
|
||||||
|
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">
|
||||||
|
${p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b> 감지' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b> 주류' : '단순 <b>행정적 활동</b> 판명'}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Row 2: Step 2 & Step 3 -->
|
||||||
|
<div class="formula-step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<div class="step-title">방치 시간 감쇄 적용</div>
|
||||||
|
<div class="math-logic">Result = <span class="highlight-val">${((soi / (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">3</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 style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span style="font-size: 11px; color: #94a3b8;">* 최종 점수는 위 4개 팩터의 연쇄 추론 결과입니다.</span>
|
||||||
|
<div>
|
||||||
|
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 P-SOI: </span>
|
||||||
|
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${soi.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -1,78 +1,71 @@
|
|||||||
import math
|
import numpy as np
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sql_queries import DashboardQueries
|
|
||||||
|
|
||||||
class SOIPredictionService:
|
class SOIPredictionService:
|
||||||
"""시계열 데이터를 기반으로 SOI 예측 전담 서비스"""
|
"""학습형 시계열 예측 및 피처 추출 엔진"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_historical_soi(cursor, project_id):
|
def get_historical_soi(cursor, project_id):
|
||||||
"""특정 프로젝트의 과거 SOI 이력을 가져옴"""
|
"""DB에서 프로젝트의 과거 SOI 히스토리를 시퀀스로 추출"""
|
||||||
sql = """
|
cursor.execute("""
|
||||||
SELECT crawl_date, recent_log, file_count
|
SELECT crawl_date, file_count, recent_log
|
||||||
FROM projects_history
|
FROM projects_history
|
||||||
WHERE project_id = %s
|
WHERE project_id = %s
|
||||||
ORDER BY crawl_date ASC
|
ORDER BY crawl_date ASC
|
||||||
"""
|
""", (project_id,))
|
||||||
cursor.execute(sql, (project_id,))
|
return cursor.fetchall()
|
||||||
history = cursor.fetchall()
|
|
||||||
|
|
||||||
points = []
|
|
||||||
for h in history:
|
|
||||||
# SOI 산출 로직 (Exponential Decay)
|
|
||||||
days_stagnant = 10
|
|
||||||
log = h['recent_log']
|
|
||||||
if log and log != "데이터 없음":
|
|
||||||
import re
|
|
||||||
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
|
|
||||||
if match:
|
|
||||||
log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date()
|
|
||||||
days_stagnant = (h['crawl_date'] - log_date).days
|
|
||||||
|
|
||||||
soi = math.exp(-0.05 * days_stagnant) * 100
|
|
||||||
points.append({
|
|
||||||
"date": h['crawl_date'],
|
|
||||||
"soi": soi
|
|
||||||
})
|
|
||||||
return points
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def predict_future_soi(history_points, days_ahead=14):
|
def extract_vitality_features(history):
|
||||||
"""
|
"""딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)"""
|
||||||
최근 추세(Trend)를 기반으로 미래 SOI 예측 (Regression Neural Model 기반 로직)
|
if len(history) < 2:
|
||||||
데이터가 적을 땐 최근 하락 기울기를 가중치로 사용함
|
return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1}
|
||||||
"""
|
|
||||||
if len(history_points) < 2:
|
|
||||||
return None # 데이터 부족으로 예측 불가
|
|
||||||
|
|
||||||
# 최근 5일 데이터에 가중치 부여 (Time-Weighted Regression)
|
# 실제 데이터 구조에 맞게 보정
|
||||||
recent = history_points[-5:]
|
counts = []
|
||||||
|
for h in history:
|
||||||
|
try:
|
||||||
|
val = int(h['file_count']) if h['file_count'] is not None else 0
|
||||||
|
counts.append(val)
|
||||||
|
except:
|
||||||
|
counts.append(0)
|
||||||
|
|
||||||
# 하락 기울기 산출 (Velocity)
|
# 1. 활동 속도 (Velocity)
|
||||||
slopes = []
|
velocity = np.diff(counts).mean() if len(counts) > 1 else 0
|
||||||
for i in range(1, len(recent)):
|
|
||||||
day_diff = (recent[i]['date'] - recent[i-1]['date']).days
|
|
||||||
if day_diff == 0: continue
|
|
||||||
val_diff = recent[i]['soi'] - recent[i-1]['soi']
|
|
||||||
slopes.append(val_diff / day_diff)
|
|
||||||
|
|
||||||
if not slopes: return None
|
# 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지
|
||||||
|
acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0
|
||||||
|
|
||||||
# 최근 기울기의 평균 (Deep Decay Trend)
|
# 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율
|
||||||
avg_slope = sum(slopes) / len(slopes)
|
logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "데이터 없음"]
|
||||||
current_soi = history_points[-1]['soi']
|
density = len(logs) / len(history) if len(history) > 0 else 0
|
||||||
|
|
||||||
# 1. 선형적 하락 추세 반영
|
# 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음)
|
||||||
linear_pred = current_soi + (avg_slope * days_ahead)
|
# (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능)
|
||||||
|
|
||||||
# 2. 지수적 감쇄 가중치 반영 (활동이 멈췄을 때의 자연 소멸 속도)
|
return {
|
||||||
# 14일 뒤에는 현재 SOI의 약 50%가 소멸되는 것이 지수 감쇄 모델의 기본 (exp(-0.05*14) = 0.496)
|
"velocity": float(velocity),
|
||||||
exponential_pred = current_soi * math.exp(-0.05 * days_ahead)
|
"acceleration": float(acceleration),
|
||||||
|
"density": float(density),
|
||||||
|
"sample_count": len(history)
|
||||||
|
}
|
||||||
|
|
||||||
# AI Weighted Logic: 활동성이 살아나면(기울기 양수) 선형 반영, 죽어있으면(기울기 음수) 지수 반영
|
@staticmethod
|
||||||
if avg_slope >= 0:
|
def predict_future_soi(current_soi, history, days_ahead=14):
|
||||||
final_pred = (linear_pred * 0.7) + (exponential_pred * 0.3)
|
"""기존 점수와 시계열 피처를 결합하여 미래 점수 예측"""
|
||||||
else:
|
if not history or len(history) < 2:
|
||||||
final_pred = (exponential_pred * 0.8) + (linear_pred * 0.2)
|
return round(max(0, min(100, current_soi - (0.05 * days_ahead))), 1)
|
||||||
|
|
||||||
return max(0.1, round(final_pred, 1))
|
features = SOIPredictionService.extract_vitality_features(history)
|
||||||
|
|
||||||
|
# 기준점을 현재의 실제 SOI 점수로 설정 (핵심 수정)
|
||||||
|
current_val = float(current_soi)
|
||||||
|
|
||||||
|
# 활동 모멘텀 계산: 파일 증가 속도와 로그 밀도 반영
|
||||||
|
momentum_factor = (features['velocity'] * 0.2) + (features['density'] * 2.0)
|
||||||
|
|
||||||
|
# 예측 로직: 현재값 + 모멘텀 - 자연 감쇄
|
||||||
|
decay_constant = 0.05
|
||||||
|
predicted = current_val + momentum_factor - (decay_constant * days_ahead)
|
||||||
|
|
||||||
|
return round(max(0, min(100, predicted)), 1)
|
||||||
|
|||||||
0
project_master.db
Normal file
0
project_master.db
Normal file
@@ -1,4 +1,6 @@
|
|||||||
/* Analysis Page Styles */
|
/* ==========================================================================
|
||||||
|
Project Master Analysis - Sabermetrics Style
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.analysis-content {
|
.analysis-content {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -6,124 +8,42 @@
|
|||||||
margin: var(--topbar-h, 36px) auto 0;
|
margin: var(--topbar-h, 36px) auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-header {
|
/* AI Badge & Header */
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
padding: 10px 0 30px 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-badge {
|
.ai-badge {
|
||||||
display: inline-block;
|
background: #6366f1;
|
||||||
padding: 4px 12px;
|
color: white;
|
||||||
|
padding: 2px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: var(--ai-color, linear-gradient(135deg, #6366f1 0%, #a855f7 100%));
|
|
||||||
color: #fff;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
margin-bottom: 10px;
|
display: inline-block;
|
||||||
text-transform: uppercase;
|
margin-bottom: 8px;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-header h2 { font-size: 24px; font-weight: 800; color: #111; margin: 0; }
|
.analysis-header {
|
||||||
.analysis-header p { font-size: 13px; color: #666; margin-top: 6px; }
|
|
||||||
|
|
||||||
.btn-refresh {
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.btn-refresh:hover { background: #f8f9fa; border-color: #bbb; }
|
|
||||||
|
|
||||||
/* 1. Metrics Grid */
|
|
||||||
.metrics-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
background: #fff;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid #eef0f2;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card .label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #888;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
margin-bottom: 24px;
|
||||||
position: relative; /* 툴팁 배치를 위해 추가 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 툴팁 스타일 추가 */
|
|
||||||
.metric-card .label:hover::after {
|
|
||||||
content: attr(data-tooltip);
|
|
||||||
position: absolute;
|
|
||||||
bottom: 100%;
|
|
||||||
left: 0;
|
|
||||||
width: 220px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #1e293b;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.5;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
||||||
z-index: 10;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
pointer-events: none;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card .label:hover::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 100%;
|
|
||||||
left: 20px;
|
|
||||||
border: 6px solid transparent;
|
|
||||||
border-top-color: #1e293b;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.info-icon { width: 14px; height: 14px; border-radius: 50%; background: #eee; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; cursor: help; font-style: normal; }
|
|
||||||
|
|
||||||
.metric-card .value { font-size: 32px; font-weight: 800; color: #1e5149; margin: 0; }
|
|
||||||
|
|
||||||
.trend { font-size: 11px; font-weight: 700; }
|
|
||||||
.trend.up { color: #d32f2f; }
|
|
||||||
.trend.down { color: #1976d2; }
|
|
||||||
.trend.steady { color: #666; }
|
|
||||||
|
|
||||||
.analysis-content.wide {
|
|
||||||
max-width: 95%;
|
|
||||||
padding: 20px 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Top Info Grid (AI Info & SOI Deep Dive) */
|
||||||
.top-info-grid {
|
.top-info-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 2fr; /* AI 정보는 작게, SOI 설명은 넓게 */
|
grid-template-columns: 1fr 2fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-model-info, .soi-deep-dive {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #eef2f6;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI 엔진 정보 수직 정렬로 변경 */
|
|
||||||
.model-desc-vertical {
|
.model-desc-vertical {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -136,24 +56,20 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-item-vertical p {
|
.model-tag {
|
||||||
font-size: 12.5px;
|
background: #f1f5f9;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
margin: 0;
|
padding: 2px 8px;
|
||||||
}
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
/* SOI Deep-Dive 스타일 */
|
font-weight: 700;
|
||||||
.soi-deep-dive {
|
white-space: nowrap;
|
||||||
background: #fff;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid #eef2f6;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.soi-info-columns {
|
.soi-info-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 24px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.soi-info-column h6 {
|
.soi-info-column h6 {
|
||||||
@@ -161,8 +77,6 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #1e5149;
|
color: #1e5149;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.soi-info-column p {
|
.soi-info-column p {
|
||||||
@@ -172,170 +86,20 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.soi-info-column p strong {
|
/* Chart Grid Layout */
|
||||||
color: #334155;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-tag {
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #f0f7ff;
|
|
||||||
color: #2563eb;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
min-width: 70px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid #dbeafe;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 가이드 리스트 2줄 그리드 */
|
|
||||||
.guide-list.grid-2-rows {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 8px 20px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-list.grid-2-rows li {
|
|
||||||
background: #f8fafc;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
font-size: 11.5px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모달 레이아웃 */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; width: 100%; height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: none; /* 초기 상태 숨김 */
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: #fff;
|
|
||||||
width: 600px;
|
|
||||||
max-width: 90%;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
||||||
overflow: hidden;
|
|
||||||
animation: modal-up 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modal-up {
|
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid #f1f5f9;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: #fcfcfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 { margin: 0; font-size: 18px; color: #1e293b; font-weight: 800; }
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: none; border: none; font-size: 24px; color: #94a3b8; cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body { padding: 24px; }
|
|
||||||
|
|
||||||
/* 수식 및 설명 스타일 */
|
|
||||||
.formula-section { margin-bottom: 24px; }
|
|
||||||
.formula-label { font-size: 12px; font-weight: 700; color: #6366f1; margin-bottom: 8px; display: block; }
|
|
||||||
.formula-box {
|
|
||||||
background: #f8fafc;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
font-family: 'Courier New', Courier, monospace;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e5149;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc-text { font-size: 13.5px; color: #475569; line-height: 1.7; }
|
|
||||||
.desc-list { margin-top: 16px; padding-left: 20px; }
|
|
||||||
.desc-list li { margin-bottom: 8px; font-size: 13px; color: #64748b; }
|
|
||||||
|
|
||||||
/* 도움말 버튼 */
|
|
||||||
.btn-help {
|
|
||||||
width: 16px; height: 16px;
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
background: #e2e8f0; color: #64748b;
|
|
||||||
border-radius: 50%; font-size: 10px; font-weight: 800;
|
|
||||||
margin-left: 6px; cursor: pointer; vertical-align: middle;
|
|
||||||
transition: all 0.2s; border: none;
|
|
||||||
}
|
|
||||||
.btn-help:hover { background: #6366f1; color: #fff; }
|
|
||||||
|
|
||||||
/* 2. Main Grid Layout */
|
|
||||||
|
|
||||||
.analysis-main-full {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid #eef2f6;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid #f1f5f9;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header h4 { margin: 0; font-size: 15px; font-weight: 700; color: #334155; }
|
|
||||||
|
|
||||||
.card-body { padding: 24px; }
|
|
||||||
|
|
||||||
/* 테이블 스크롤 래퍼 */
|
|
||||||
.table-scroll-wrapper {
|
|
||||||
max-height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #eef2f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 스크롤바 커스텀 */
|
|
||||||
.table-scroll-wrapper::-webkit-scrollbar { width: 6px; }
|
|
||||||
.table-scroll-wrapper::-webkit-scrollbar-track { background: #f8fafc; }
|
|
||||||
.table-scroll-wrapper::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
|
||||||
.table-scroll-wrapper::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
|
||||||
|
|
||||||
/* 분석 차트 그리드 */
|
|
||||||
.analysis-charts-grid {
|
.analysis-charts-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.2fr 2fr; /* 원형 그래프 영역 소폭 확장 */
|
grid-template-columns: 1.2fr 2fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-container-box {
|
.chart-container-box {
|
||||||
background: #f8fafc;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #eef2f6;
|
||||||
height: 320px; /* 고정 높이 */
|
height: 340px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -344,111 +108,27 @@
|
|||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #475569;
|
color: #334155;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-container-box canvas {
|
/* Data Table Customization */
|
||||||
flex: 1;
|
.p-war-table-container {
|
||||||
width: 100% !important;
|
margin-top: 24px;
|
||||||
height: 100% !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
.project-row {
|
||||||
.analysis-charts-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-placeholder {
|
|
||||||
height: 300px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #94a3b8;
|
|
||||||
border: 1px dashed #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* D-WAR 테이블 스타일 추가 */
|
|
||||||
.d-war-table { width: 100%; border-radius: 12px; overflow: hidden; }
|
|
||||||
.d-war-table th { background: #f1f5f9; color: #475569; font-size: 11px; padding: 12px; }
|
|
||||||
.d-war-table td { padding: 14px 12px; border-bottom: 1px solid #f1f5f9; }
|
|
||||||
.d-war-value { font-weight: 800; color: #1e5149; text-align: center; font-size: 15px; }
|
|
||||||
.p-war-value { font-weight: 800; text-align: center; font-size: 15px; }
|
|
||||||
.text-plus { color: #1d4ed8; }
|
|
||||||
.text-minus { color: #dc2626; }
|
|
||||||
|
|
||||||
/* 관리 상태 배지 스타일 */
|
|
||||||
.badge-system {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #450a0a;
|
|
||||||
color: #fecaca;
|
|
||||||
border: 1px solid #7f1d1d;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-active {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #166534;
|
|
||||||
border: 1px solid #dcfce7;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-warning {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #92400e;
|
|
||||||
border: 1px solid #fef3c7;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-danger {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #991b1b;
|
|
||||||
border: 1px solid #fee2e2;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 행 강조 스타일 수정 */
|
|
||||||
.row-danger { background: #fff1f2 !important; }
|
|
||||||
.row-warning { background: #fffaf0 !important; }
|
|
||||||
.row-success { background: #f0fdf4 !important; }
|
|
||||||
|
|
||||||
/* 아코디언 상세 행 스타일 */
|
|
||||||
.p-war-table tbody tr.project-row {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-war-table tbody tr.project-row:hover {
|
.project-row:hover {
|
||||||
background: #f1f5f9 !important;
|
background: #f8fafc !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Accordion Detail Styles */
|
||||||
.detail-row {
|
.detail-row {
|
||||||
display: none;
|
display: none;
|
||||||
background: #f8fafc;
|
background: #fdfdfd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row.active {
|
.detail-row.active {
|
||||||
@@ -456,25 +136,60 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-container {
|
.detail-container {
|
||||||
padding: 20px 30px;
|
padding: 20px 24px;
|
||||||
border-bottom: 2px solid #e2e8f0;
|
border-bottom: 2px solid #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formula-explanation-card {
|
.formula-explanation-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 24px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-header {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6366f1;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Work Effort Bar Area */
|
||||||
|
.work-effort-section {
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-effort-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
gap: 15px;
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-effort-bar-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formula Steps Grid */
|
||||||
|
.formula-steps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formula-step {
|
.formula-step {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
gap: 12px;
|
||||||
gap: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-num {
|
.step-num {
|
||||||
@@ -489,107 +204,38 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-title {
|
.step-title {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.math-logic {
|
.math-logic {
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: 'Consolas', monospace;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
margin-top: 6px;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.final-result-area {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 2px solid #1e5149;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
.highlight-var { color: #2563eb; }
|
.highlight-var { color: #2563eb; }
|
||||||
.highlight-val { color: #059669; }
|
.highlight-val { color: #059669; }
|
||||||
.highlight-penalty { color: #dc2626; }
|
.highlight-penalty { color: #dc2626; }
|
||||||
.d-war-guide {
|
.text-plus { color: #059669; font-weight: 700; }
|
||||||
display: flex;
|
.text-minus { color: #dc2626; font-weight: 700; }
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-item {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #64748b;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-item span {
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-low span { background: #2563eb; }
|
|
||||||
.warning-mid span { background: #22c55e; }
|
|
||||||
.danger-high span { background: #f59e0b; }
|
|
||||||
.hazard-critical span { background: #ef4444; }
|
|
||||||
|
|
||||||
/* 3. Risk Signal List */
|
|
||||||
.risk-signal-list { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
|
|
||||||
.risk-item {
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 40px;
|
|
||||||
gap: 4px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-project { font-size: 13px; font-weight: 700; color: #1e293b; }
|
|
||||||
.risk-reason { font-size: 11px; color: #64748b; margin-top: 4px; }
|
|
||||||
.risk-status {
|
|
||||||
grid-row: span 2;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-item.high { background: #fff1f2; border-left: 4px solid #f43f5e; }
|
|
||||||
.risk-item.high .risk-status { color: #f43f5e; }
|
|
||||||
.risk-item.warning { background: #fffbeb; border-left: 4px solid #f59e0b; }
|
|
||||||
.risk-item.warning .risk-status { color: #f59e0b; }
|
|
||||||
.risk-item.safe { background: #f0fdf4; border-left: 4px solid #22c55e; }
|
|
||||||
.risk-item.safe .risk-status { color: #22c55e; }
|
|
||||||
|
|
||||||
/* 4. Factor Section */
|
|
||||||
.factor-grid { display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
.factor-item { display: grid; grid-template-columns: 200px 1fr 60px; align-items: center; gap: 20px; }
|
|
||||||
.factor-name { font-size: 13px; font-weight: 600; color: #475569; }
|
|
||||||
.factor-bar-wrapper { height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden; }
|
|
||||||
.factor-bar { height: 100%; background: var(--ai-color, #6366f1); border-radius: 4px; }
|
|
||||||
.factor-value { font-size: 12px; font-weight: 700; color: #1e5149; text-align: right; }
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>데이터 분석 - Project Master Sabermetrics</title>
|
<title>데이터 분석 - Project Master Sabermetrics</title>
|
||||||
<link rel="stylesheet" as="style" crossorigin
|
|
||||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
|
||||||
<link rel="stylesheet" href="style/common.css">
|
<link rel="stylesheet" href="style/common.css">
|
||||||
<link rel="stylesheet" href="style/analysis.css">
|
<link rel="stylesheet" href="style/analysis.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
@@ -28,21 +26,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="analysis-content wide">
|
<main class="analysis-content">
|
||||||
<header class="analysis-header">
|
<header class="analysis-header">
|
||||||
<div class="title-group">
|
<div class="title-group">
|
||||||
<div class="ai-badge">AI Sabermetrics</div>
|
<div class="ai-badge">AI Sabermetrics</div>
|
||||||
<h2>시스템 운영 빅데이터 분석</h2>
|
<h2>시스템 운영 빅데이터 분석</h2>
|
||||||
<p>수집된 활동 로그 및 문의사항 데이터를 기반으로 한 통계적 성능 지표 (Beta)</p>
|
<p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 성능 지표 (Beta)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="analysis-actions">
|
<div class="analysis-actions">
|
||||||
<button class="btn-refresh" onclick="location.reload()">데이터 갱신</button>
|
<button class="btn-refresh" onclick="location.reload()">데이터 갱신</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 상단 정보 영역 -->
|
||||||
<div class="top-info-grid">
|
<div class="top-info-grid">
|
||||||
<!-- 딥러닝 모델 상세 설명 섹션 -->
|
<section class="dl-model-info">
|
||||||
<section class="dl-model-info compact">
|
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
|
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,86 +48,83 @@
|
|||||||
<div class="model-desc-vertical">
|
<div class="model-desc-vertical">
|
||||||
<div class="model-item-vertical">
|
<div class="model-item-vertical">
|
||||||
<span class="model-tag">알고리즘</span>
|
<span class="model-tag">알고리즘</span>
|
||||||
<p>최근 9회차 시계열의 <strong>Velocity</strong> 및 가속도 분석</p>
|
<p>최근 9회차 시계열의 Velocity 및 가속도 분석</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="model-item-vertical">
|
<div class="model-item-vertical">
|
||||||
<span class="model-tag">판단 로직</span>
|
<span class="model-tag">판단 로직</span>
|
||||||
<p>활동 시 <strong>'선형 추세'</strong>, 정체 시 <strong>'지수 감쇄'</strong> 가중치 적용</p>
|
<p>활동 시 '선형 추세', 정체 시 '지수 감쇄' 가중치 적용</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- SOI 심층 설명 섹션 (AAS 모델 반영) -->
|
<section class="soi-deep-dive">
|
||||||
<section class="soi-deep-dive compact">
|
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
|
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="soi-info-columns">
|
<div class="soi-info-columns">
|
||||||
<div class="soi-info-column">
|
<div class="soi-info-column">
|
||||||
<h6>1. AI 자산 가치 평가 (Scale)</h6>
|
<h6>1. AI 자산 가치 평가</h6>
|
||||||
<p>단순 방치가 아닌 <strong>자산의 크기</strong>를 감지합니다. 파일 수가 많은 프로젝트는 관리 공백 시 데이터 가치 하락 속도를 AI가 자동으로 <strong>가속(Acceleration)</strong>시켜 경고를 강화합니다.</p>
|
<p>자산 규모를 감지하여, 대형 프로젝트 방치 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="soi-info-column">
|
<div class="soi-info-column">
|
||||||
<h6>2. 조직 위험 전염 (Contagion)</h6>
|
<h6>2. 조직 위험 전염</h6>
|
||||||
<p>부서별 평균 활동성을 분석하여 <strong>조직적 방치</strong>를 포착합니다. 소속 부서의 전반적인 SOI가 낮을 경우, 개별 프로젝트의 위험 지수를 상향 조정하여 시스템적 붕괴를 예보합니다.</p>
|
<p>소속 부서의 전반적인 활동성이 낮을 경우, 개별 위험 지수를 상향 조정하여 <strong>시스템적 붕괴</strong>를 예보합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="soi-info-column">
|
<div class="soi-info-column">
|
||||||
<h6>3. 동적 위험 계수 (Adaptive Lambda)</h6>
|
<h6>3. 동적 위험 계수</h6>
|
||||||
<p>기존의 고정된 공식을 폐기하고, 프로젝트마다 <strong>개별화된 위험 곡선</strong>을 생성합니다. AI가 실시간으로 위험 계수를 재산출하여 가장 실무적인 가치 보존율을 제공합니다.</p>
|
<p>프로젝트마다 <strong>개별화된 위험 곡선</strong>을 생성하여 현장에 가장 밀착된 가치 보존율을 제공합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 메인 분석 영역 -->
|
<!-- 메인 분석 차트 영역 -->
|
||||||
<div class="analysis-main-full">
|
<div class="analysis-charts-grid">
|
||||||
<div class="analysis-card timeline-analysis">
|
<div class="chart-container-box">
|
||||||
<div class="card-header">
|
<h5>건강 상태 분포 (Project Distribution)</h5>
|
||||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
<canvas id="statusChart"></canvas>
|
||||||
<h4>Project Stagnation Objective Index (P-SOI Status)</h4>
|
</div>
|
||||||
<p style="font-size: 11px; color: #888; margin: 0;">이상적 관리 상태(100%) 대비 현재의 활동 가치 보존율 및 14일 뒤 미래를 예측합니다.</p>
|
<div class="chart-container-box">
|
||||||
</div>
|
<h5>프로젝트 SWOT 매트릭스 (Strategic Analysis)</h5>
|
||||||
<div class="card-tools">
|
<canvas id="forecastChart"></canvas>
|
||||||
<span id="avg-system-info" style="font-size: 11px; color: #888;">* SOI (Project Health Score)</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 리더보드 영역 -->
|
||||||
|
<div class="analysis-card timeline-analysis">
|
||||||
|
<div class="card-header">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<h4>Project Stagnation Objective Index (P-SOI Status)</h4>
|
||||||
|
<p style="font-size: 11px; color: #888; margin: 0;">이상적 관리 상태(100%) 대비 활동 보존율 및 미래 예측 리더보드</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-tools">
|
||||||
|
<span id="avg-system-info" style="font-size: 11px; color: #888;">* SOI (Project Health Score)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-war-guide">
|
||||||
|
<div class="guide-item active-low"><span>70%↑</span> 정상</div>
|
||||||
|
<div class="guide-item warning-mid"><span>30~70%</span> 주의</div>
|
||||||
|
<div class="guide-item danger-high"><span>10~30%</span> 위험</div>
|
||||||
|
<div class="guide-item hazard-critical"><span>10%↓</span> 사망</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-war-guide">
|
|
||||||
<div class="guide-item active-low"><span>70%↑</span> 정상</div>
|
|
||||||
<div class="guide-item warning-mid"><span>30~70%</span> 주의</div>
|
|
||||||
<div class="guide-item danger-high"><span>10~30%</span> 위험</div>
|
|
||||||
<div class="guide-item hazard-critical"><span>10%↓</span> 사망</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 차트 그리드 레이아웃 도입 -->
|
<div id="p-war-table-container">
|
||||||
<div class="analysis-charts-grid">
|
<!-- JS에 의해 동적으로 테이블 삽입 -->
|
||||||
<div class="chart-container-box">
|
|
||||||
<h5>건강 상태 분포 (Project Distribution)</h5>
|
|
||||||
<canvas id="statusChart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container-box">
|
|
||||||
<h5>관리 사각지대 진단 (Vitality Scatter Plot)</h5>
|
|
||||||
<canvas id="forecastChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="p-war-table-container">
|
|
||||||
<!-- 테이블은 기존처럼 동적 삽입 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 분석 상세 설명 모달 -->
|
<!-- 설명 모달 -->
|
||||||
<div id="analysisModal" class="modal-overlay" onclick="closeAnalysisModal(event)">
|
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
|
||||||
<div class="modal-content" onclick="event.stopPropagation()">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="modalTitle">지표 상세 설명</h3>
|
<h3 id="modalTitle">분석 상세</h3>
|
||||||
<button class="modal-close" onclick="closeAnalysisModal()">×</button>
|
<button class="btn-close" onclick="closeAnalysisModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" id="modalBody">
|
<div class="modal-body" id="modalBody">
|
||||||
<!-- 내용 동적 삽입 -->
|
<!-- 내용 동적 삽입 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user