add analysis python code & reorganize folder
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>스타트업 지원 심사 판단 실험</title>
|
||||
<link rel="stylesheet" href="css/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="app-header"></header>
|
||||
<main id="app-main"></main>
|
||||
</div>
|
||||
|
||||
<script src="js/cases.js"></script>
|
||||
<script src="js/ahp.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
// PAIRS: indices into VARS array [0=안정성, 1=수익_가능성, 2=자금_효율성, 3=사업_계획_완성도, 4=사회적_가치]
|
||||
const AHP_PAIRS = [
|
||||
[0,1],[0,2],[0,3],[0,4],
|
||||
[1,2],[1,3],[1,4],
|
||||
[2,3],[2,4],
|
||||
[3,4]
|
||||
];
|
||||
|
||||
function buildMatrix(pairwiseResults) {
|
||||
const n = 5;
|
||||
const matrix = Array.from({length: n}, () => Array(n).fill(1));
|
||||
AHP_PAIRS.forEach(([i, j], idx) => {
|
||||
const val = pairwiseResults[idx];
|
||||
if (val >= 1) {
|
||||
matrix[i][j] = val;
|
||||
matrix[j][i] = 1 / val;
|
||||
} else {
|
||||
matrix[j][i] = Math.abs(val);
|
||||
matrix[i][j] = 1 / Math.abs(val);
|
||||
}
|
||||
});
|
||||
return matrix;
|
||||
}
|
||||
|
||||
function powerMethod(matrix, maxIter = 1000, threshold = 1e-6) {
|
||||
const n = matrix.length;
|
||||
let v = Array(n).fill(1 / n);
|
||||
for (let iter = 0; iter < maxIter; iter++) {
|
||||
const Av = Array(n).fill(0);
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = 0; j < n; j++)
|
||||
Av[i] += matrix[i][j] * v[j];
|
||||
const sum = Av.reduce((a, b) => a + b, 0);
|
||||
const vNew = Av.map(x => x / sum);
|
||||
const maxDiff = Math.max(...vNew.map((x, k) => Math.abs(x - v[k])));
|
||||
v = vNew;
|
||||
if (maxDiff < threshold) break;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function calcConsistency(matrix, weights) {
|
||||
const n = matrix.length;
|
||||
const RI = [0, 0, 0.58, 0.90, 1.12, 1.24, 1.32, 1.41, 1.45];
|
||||
const Aw = Array(n).fill(0);
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = 0; j < n; j++)
|
||||
Aw[i] += matrix[i][j] * weights[j];
|
||||
const lambdaMax = Aw.reduce((s, v, i) => s + v / weights[i], 0) / n;
|
||||
const CI = (lambdaMax - n) / (n - 1);
|
||||
const CR = CI / RI[n];
|
||||
return { lambdaMax, CI, CR };
|
||||
}
|
||||
|
||||
function calcModelScore(caseData, weights) {
|
||||
return VARS.reduce((sum, varKey, i) => sum + caseData[varKey] * weights[i], 0);
|
||||
}
|
||||
|
||||
// Slider positions -4 to +4 map to AHP odd values 1,3,5,7,9
|
||||
// pos=0 → 1 (equal)
|
||||
// pos<0 → left more important, AHP value = 2*|pos|+1 (1,3,5,7,9)
|
||||
// pos>0 → right more important, stored as negative: -(2*pos+1)
|
||||
function sliderToStored(sliderPos) {
|
||||
if (sliderPos === 0) return 1;
|
||||
if (sliderPos < 0) return 2 * Math.abs(sliderPos) + 1;
|
||||
return -(2 * sliderPos + 1);
|
||||
}
|
||||
|
||||
function storedToSlider(val) {
|
||||
if (val === 1) return 0;
|
||||
if (val > 1) return -((val - 1) / 2); // left was important
|
||||
return (Math.abs(val) - 1) / 2; // right was important
|
||||
}
|
||||
|
||||
function ahpValueToScale(absVal) {
|
||||
// absVal is 2-9
|
||||
const levels = { 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9' };
|
||||
return absVal;
|
||||
}
|
||||
|
||||
function runAHP(pairwiseRaw) {
|
||||
const matrix = buildMatrix(pairwiseRaw);
|
||||
const weights = powerMethod(matrix);
|
||||
const { lambdaMax, CI, CR } = calcConsistency(matrix, weights);
|
||||
return { weights, lambdaMax, CI, CR, matrix };
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
// ──────────────────────────────────────────
|
||||
// Text (한국어 전용)
|
||||
// ──────────────────────────────────────────
|
||||
const APP_TITLE = '스타트업 지원 심사 판단 실험';
|
||||
|
||||
const GRADE_OPTIONS = ['1학년', '2학년', '3학년'];
|
||||
|
||||
const VAR_LABELS = {
|
||||
안정성: '안정성',
|
||||
수익_가능성: '수익 가능성',
|
||||
자금_효율성: '자금 효율성',
|
||||
사업_계획_완성도: '사업 계획 완성도',
|
||||
사회적_가치: '사회적 가치',
|
||||
};
|
||||
|
||||
const JUDGMENT_SCALE = ['무조건\n탈락', '탈락', '보통', '선발', '무조건\n선발'];
|
||||
|
||||
const AHP_LEVELS = { 3: '약간', 5: '꽤', 7: '매우', 9: '극히' };
|
||||
|
||||
const LIKERT_QUESTIONS = [
|
||||
'모델의 판단 결과를 보고 놀라웠다.',
|
||||
'내 직관보다 모델의 판단이 더 합리적이라고 생각한다.',
|
||||
'이 선발 결정을 모델에게 맡겨도 괜찮다고 생각한다.',
|
||||
'이번 판단은 온전히 내 의지로 한 것이라고 느낀다.',
|
||||
];
|
||||
|
||||
const LIKERT_SCALE = [
|
||||
'전혀\n아니다',
|
||||
'아니다',
|
||||
'보통',
|
||||
'그렇다',
|
||||
'매우\n그렇다',
|
||||
];
|
||||
|
||||
const ALERT_FILL_ALL = '모든 항목을 입력해 주세요.';
|
||||
const ALERT_SELECT_JUDGMENT = '먼저 판단을 선택해 주세요.';
|
||||
const LEAVE_WARNING = '실험이 완료되지 않았습니다. 나가시겠습니까?';
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// State
|
||||
// ──────────────────────────────────────────
|
||||
const state = {
|
||||
step: 1,
|
||||
started: false,
|
||||
|
||||
// Step 1
|
||||
meta: { grade: '', timestamp: null },
|
||||
|
||||
// Step 2
|
||||
shuffledCases: [],
|
||||
caseIndex: 0,
|
||||
currentJudgment: null, // 1-5 Likert
|
||||
caseStartTime: null,
|
||||
intuitive_judgments: [],
|
||||
|
||||
// Step 3
|
||||
pairIndex: 0,
|
||||
sliderValues: Array(10).fill(0),
|
||||
pairwiseRaw: null,
|
||||
ahpResult: null,
|
||||
|
||||
// Step 4 / 5
|
||||
comparison: [],
|
||||
post_survey: { q1: null, q2: null, q3: null, q4: null, open_response: '' },
|
||||
submitted: false,
|
||||
_payload: null,
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Render entry point
|
||||
// ──────────────────────────────────────────
|
||||
function renderApp() {
|
||||
renderHeader();
|
||||
renderMain();
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const el = document.getElementById('app-header');
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="header-inner">
|
||||
<span class="header-title">${APP_TITLE}</span>
|
||||
<span class="header-name">대구국제고 3학년 5반 이승준</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMain() {
|
||||
const el = document.getElementById('app-main');
|
||||
if (!el) return;
|
||||
switch (state.step) {
|
||||
case 1:
|
||||
el.innerHTML = renderStep1();
|
||||
bindStep1();
|
||||
break;
|
||||
case 2:
|
||||
el.innerHTML = renderStep2();
|
||||
bindStep2();
|
||||
break;
|
||||
case 3:
|
||||
el.innerHTML = renderStep3();
|
||||
bindStep3();
|
||||
break;
|
||||
case 4:
|
||||
el.innerHTML = renderStep4();
|
||||
break;
|
||||
case 5:
|
||||
el.innerHTML = renderStep5();
|
||||
bindStep5();
|
||||
break;
|
||||
case 'complete':
|
||||
el.innerHTML = renderComplete();
|
||||
bindComplete();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// STEP 1
|
||||
// ──────────────────────────────────────────
|
||||
function renderStep1() {
|
||||
const { meta } = state;
|
||||
const gradeOpts = GRADE_OPTIONS.map(
|
||||
(o) => `
|
||||
<label class="${meta.grade === o ? 'selected' : ''}">
|
||||
<input type="radio" name="grade" value="${o}" ${meta.grade === o ? 'checked' : ''} />
|
||||
${o}
|
||||
</label>
|
||||
`,
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<div class="card">
|
||||
<h1 class="section-title">스타트업 지원 심사 판단 실험</h1>
|
||||
<p class="section-subtitle">당신은 스타트업 지원 프로그램의 심사위원입니다. 제기되는 질문에 따라서 성실히 답변해주시면 감사하겠습니다.<br>총 소요 시간은 10분 이내입니다.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>학년</label>
|
||||
<div class="radio-group" id="rg-grade">${gradeOpts}</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="margin-top:1.5rem">
|
||||
<button class="btn btn-primary btn-lg btn-full" id="btn-start">실험 시작</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindStep1() {
|
||||
document.getElementById('rg-grade').addEventListener('click', (e) => {
|
||||
const lbl = e.target.closest('label');
|
||||
if (!lbl) return;
|
||||
const val = lbl.querySelector('input').value;
|
||||
state.meta.grade = val;
|
||||
document
|
||||
.querySelectorAll('#rg-grade label')
|
||||
.forEach((l) =>
|
||||
l.classList.toggle(
|
||||
'selected',
|
||||
l.querySelector('input').value === val,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById('btn-start').addEventListener('click', () => {
|
||||
if (!state.meta.grade) {
|
||||
alert(ALERT_FILL_ALL);
|
||||
return;
|
||||
}
|
||||
state.meta.timestamp = Date.now();
|
||||
state.shuffledCases = shuffleCases();
|
||||
state.caseIndex = 0;
|
||||
state.started = true;
|
||||
enableLeaveWarning();
|
||||
goStep(2);
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// STEP 2
|
||||
// ──────────────────────────────────────────
|
||||
function renderStep2() {
|
||||
const total = state.shuffledCases.length;
|
||||
const idx = state.caseIndex;
|
||||
const pct = Math.round((idx / total) * 100);
|
||||
const c = state.shuffledCases[idx];
|
||||
|
||||
const varRows = VARS.map((key) => {
|
||||
const score = c[key];
|
||||
const level = getScoreLevel(score);
|
||||
return `
|
||||
<div class="var-row">
|
||||
<span class="var-name">${VAR_LABELS[key]}</span>
|
||||
<div class="var-bar-track">
|
||||
<div class="var-bar-fill" data-level="${level}" style="width:${score}%"></div>
|
||||
</div>
|
||||
<span class="var-score">${score}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const likertBtns = JUDGMENT_SCALE.map((label, i) => {
|
||||
const val = i + 1;
|
||||
const active = state.currentJudgment === val;
|
||||
return `
|
||||
<button class="judgment-likert-btn ${active ? 'active' : ''}" data-val="${val}">
|
||||
<span class="jl-num">${val}</span>
|
||||
<span class="jl-label">${label.replace('\n', '<br>')}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<p class="step-instruction">당신은 스타트업 지원 프로그램의 심사위원입니다. 아래 팀의 정보를 보고 지원금 지급 가능성을 직관적으로 평가해주세요.</p>
|
||||
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-label">
|
||||
<span>${idx + 1} / ${total}</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card case-card">
|
||||
<div class="case-header">
|
||||
<span class="team-badge">Team ${c.teamLetter}</span>
|
||||
</div>
|
||||
|
||||
<div>${varRows}</div>
|
||||
|
||||
<div class="judgment-section">
|
||||
<p class="judgment-label">이 팀의 지원금 지급 가능성을 평가하세요</p>
|
||||
<div class="judgment-likert-row" id="jl-row">${likertBtns}</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row end" style="margin-top:1.2rem">
|
||||
<button class="btn btn-primary" id="btn-case-next" ${state.currentJudgment === null ? 'disabled' : ''}>다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindStep2() {
|
||||
const caseStart = Date.now();
|
||||
|
||||
document.getElementById('jl-row').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.judgment-likert-btn');
|
||||
if (!btn) return;
|
||||
const val = parseInt(btn.dataset.val);
|
||||
state.currentJudgment = val;
|
||||
document
|
||||
.querySelectorAll('.judgment-likert-btn')
|
||||
.forEach((b) =>
|
||||
b.classList.toggle('active', parseInt(b.dataset.val) === val),
|
||||
);
|
||||
document.getElementById('btn-case-next').disabled = false;
|
||||
});
|
||||
|
||||
document.getElementById('btn-case-next').addEventListener('click', () => {
|
||||
if (state.currentJudgment === null) {
|
||||
alert(ALERT_SELECT_JUDGMENT);
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - caseStart;
|
||||
const c = state.shuffledCases[state.caseIndex];
|
||||
state.intuitive_judgments.push({
|
||||
case_id: c.id,
|
||||
presented_order: state.caseIndex + 1,
|
||||
judgment: state.currentJudgment, // 1-5
|
||||
response_time_ms: elapsed,
|
||||
});
|
||||
state.caseIndex++;
|
||||
state.currentJudgment = null;
|
||||
if (state.caseIndex >= state.shuffledCases.length) {
|
||||
goStep(3);
|
||||
} else {
|
||||
renderMain();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// STEP 3
|
||||
// ──────────────────────────────────────────
|
||||
const AHP_PAIR_LABELS = [
|
||||
['안정성', '수익 가능성'],
|
||||
['안정성', '자금 효율성'],
|
||||
['안정성', '사업 계획 완성도'],
|
||||
['안정성', '사회적 가치'],
|
||||
['수익 가능성', '자금 효율성'],
|
||||
['수익 가능성', '사업 계획 완성도'],
|
||||
['수익 가능성', '사회적 가치'],
|
||||
['자금 효율성', '사업 계획 완성도'],
|
||||
['자금 효율성', '사회적 가치'],
|
||||
['사업 계획 완성도', '사회적 가치'],
|
||||
];
|
||||
|
||||
function getPairLabels(idx) {
|
||||
return AHP_PAIR_LABELS[idx];
|
||||
}
|
||||
|
||||
function getAHPDescription(sliderPos, leftLabel, rightLabel) {
|
||||
if (sliderPos === 0) return '두 기준이 동등하게 중요합니다';
|
||||
const ahpVal = Math.abs(sliderToStored(sliderPos)); // 3,5,7,9
|
||||
const level = AHP_LEVELS[ahpVal] || String(ahpVal);
|
||||
const crit = sliderPos < 0 ? leftLabel : rightLabel;
|
||||
return `${crit}이(가) ${level} 더 중요합니다`;
|
||||
}
|
||||
|
||||
function renderStep3() {
|
||||
const idx = state.pairIndex;
|
||||
const total = AHP_PAIRS.length;
|
||||
const pct = Math.round((idx / total) * 100);
|
||||
const [left, right] = getPairLabels(idx);
|
||||
const sliderVal = state.sliderValues[idx];
|
||||
const desc = getAHPDescription(sliderVal, left, right);
|
||||
|
||||
const leftActive = sliderVal < 0 ? 'crit-active-left' : '';
|
||||
const rightActive = sliderVal > 0 ? 'crit-active-right' : '';
|
||||
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<p class="step-instruction">제시되는 2개의 평가 기준 중 어느 것이 더 중요한지 비교해주세요. 왼쪽이 더 중요하면 슬라이더를 왼쪽으로, 오른쪽이 더 중요하면 오른쪽으로 움직이세요.</p>
|
||||
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-label">
|
||||
<span>${idx + 1} / ${total}</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="font-size:1.1rem">AHP 쌍대비교</h2>
|
||||
|
||||
<div class="ahp-pair-card">
|
||||
<div class="pair-criteria">
|
||||
<div class="crit crit-left ${leftActive}" id="crit-left">${left}</div>
|
||||
<div class="vs-badge">VS</div>
|
||||
<div class="crit crit-right ${rightActive}" id="crit-right">${right}</div>
|
||||
</div>
|
||||
|
||||
<div class="ahp-slider-wrap">
|
||||
<div class="ahp-scale-labels">
|
||||
<span>9</span><span>7</span><span>5</span><span>3</span>
|
||||
<span class="sc-mid">1</span>
|
||||
<span>3</span><span>5</span><span>7</span><span>9</span>
|
||||
</div>
|
||||
<input type="range" class="ahp-slider" id="ahp-slider"
|
||||
min="-4" max="4" step="1" value="${sliderVal}" />
|
||||
</div>
|
||||
|
||||
<div class="ahp-description" id="ahp-desc">${desc}</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row end" style="margin-top:1.2rem">
|
||||
<button class="btn btn-primary" id="btn-pair-next">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindStep3() {
|
||||
const idx = state.pairIndex;
|
||||
const [left, right] = getPairLabels(idx);
|
||||
|
||||
const slider = document.getElementById('ahp-slider');
|
||||
if (slider) {
|
||||
slider.addEventListener('input', (e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
state.sliderValues[idx] = val;
|
||||
document.getElementById('ahp-desc').textContent = getAHPDescription(
|
||||
val,
|
||||
left,
|
||||
right,
|
||||
);
|
||||
document.getElementById('crit-left').className =
|
||||
`crit crit-left ${val < 0 ? 'crit-active-left' : ''}`;
|
||||
document.getElementById('crit-right').className =
|
||||
`crit crit-right ${val > 0 ? 'crit-active-right' : ''}`;
|
||||
});
|
||||
}
|
||||
|
||||
const nextBtn = document.getElementById('btn-pair-next');
|
||||
if (nextBtn) nextBtn.addEventListener('click', advancePair);
|
||||
}
|
||||
|
||||
function advancePair() {
|
||||
const total = AHP_PAIRS.length;
|
||||
if (state.pairIndex < total - 1) {
|
||||
state.pairIndex++;
|
||||
renderMain();
|
||||
} else {
|
||||
const raw = state.sliderValues.map((s) => sliderToStored(s));
|
||||
state.pairwiseRaw = raw;
|
||||
// CR은 분석용으로 계속 기록하되, 모순이 있어도 재입력 없이 그대로 진행
|
||||
state.ahpResult = runAHP(raw);
|
||||
finalizeAHP();
|
||||
goStep(4);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Likert 1-5 to judgment category for comparison with model
|
||||
function likertToJudgment(likert) {
|
||||
if (likert >= 4) return 'select';
|
||||
if (likert <= 2) return 'reject';
|
||||
return 'neutral'; // 3
|
||||
}
|
||||
|
||||
function finalizeAHP() {
|
||||
const weights = state.ahpResult.weights;
|
||||
state.comparison = state.shuffledCases.map((c, i) => {
|
||||
const modelScore = calcModelScore(c, weights);
|
||||
const modelJudgment = modelScore >= 50 ? 'select' : 'reject';
|
||||
const rawLikert = state.intuitive_judgments[i]?.judgment ?? null;
|
||||
const intuitiveCategory =
|
||||
rawLikert !== null ? likertToJudgment(rawLikert) : null;
|
||||
const match =
|
||||
intuitiveCategory === 'neutral'
|
||||
? null
|
||||
: intuitiveCategory === modelJudgment
|
||||
? true
|
||||
: false;
|
||||
return {
|
||||
case_id: c.id,
|
||||
team: c.teamLetter,
|
||||
type: c.type,
|
||||
model_score: Math.round(modelScore * 10) / 10,
|
||||
model_judgment: modelJudgment,
|
||||
intuitive_likert: rawLikert,
|
||||
intuitive_category: intuitiveCategory,
|
||||
match,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// STEP 4
|
||||
// ──────────────────────────────────────────
|
||||
function renderStep4() {
|
||||
const weights = state.ahpResult.weights;
|
||||
|
||||
const weightRows = VARS.map((key, i) => {
|
||||
const pct = Math.round(weights[i] * 100);
|
||||
return `
|
||||
<div class="wc-row">
|
||||
<span class="wc-label">${VAR_LABELS[key]}</span>
|
||||
<div class="wc-track"><div class="wc-fill" style="width:${pct}%"></div></div>
|
||||
<span class="wc-pct">${pct}%</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const comp = state.comparison;
|
||||
// Exclude neutral (match===null) from mismatch counts
|
||||
const decidable = comp.filter((c) => c.match !== null);
|
||||
const totalMismatch = decidable.filter((c) => !c.match).length;
|
||||
const baseline = decidable.filter((c) => c.type === 'baseline');
|
||||
const spike = decidable.filter((c) => c.type === 'spike');
|
||||
const conflict = decidable.filter((c) => c.type === 'conflict');
|
||||
const { CR } = state.ahpResult;
|
||||
|
||||
const rows = comp
|
||||
.map((c) => {
|
||||
const mis = c.match === false;
|
||||
const neu = c.match === null;
|
||||
const mLabel =
|
||||
c.model_judgment === 'select'
|
||||
? `<span class="badge badge-select">선발</span>`
|
||||
: `<span class="badge badge-reject">탈락</span>`;
|
||||
const iLabel =
|
||||
c.intuitive_category === 'select'
|
||||
? `<span class="badge badge-select">${c.intuitive_likert}</span>`
|
||||
: c.intuitive_category === 'reject'
|
||||
? `<span class="badge badge-reject">${c.intuitive_likert}</span>`
|
||||
: `<span class="badge badge-neutral">${c.intuitive_likert}</span>`;
|
||||
const matchBadge =
|
||||
c.match === true
|
||||
? `<span class="badge badge-match">✓</span>`
|
||||
: c.match === false
|
||||
? `<span class="badge badge-mismatch">✗</span>`
|
||||
: `<span class="badge badge-neutral">△</span>`;
|
||||
return `
|
||||
<tr class="${mis ? 'mismatch' : neu ? 'neutral-row' : ''}">
|
||||
<td>Team ${c.team}</td>
|
||||
<td>${c.model_score}</td>
|
||||
<td>${mLabel}</td>
|
||||
<td>${iLabel}</td>
|
||||
<td>${matchBadge}</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<div class="card">
|
||||
<h2 class="section-title">결과 비교</h2>
|
||||
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.75rem">나의 판단 기준 (AHP 가중치)</h3>
|
||||
<div class="weight-chart">${weightRows}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">핵심 통계</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box ${totalMismatch > 7 ? 'stat-warn' : 'stat-ok'}">
|
||||
<div class="stat-val">${totalMismatch}/${decidable.length}</div>
|
||||
<div class="stat-label">전체 불일치율</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-val">${baseline.filter((c) => !c.match).length}/${baseline.length}</div>
|
||||
<div class="stat-label">베이스라인<br>불일치율</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-val">${spike.filter((c) => !c.match).length}/${spike.length}</div>
|
||||
<div class="stat-label">스파이크 사례<br>불일치율</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-val">${conflict.filter((c) => !c.match).length}/${conflict.length}</div>
|
||||
<div class="stat-label">충돌 사례<br>불일치율</div>
|
||||
</div>
|
||||
<div class="stat-box ${CR > 0.1 ? 'stat-warn' : 'stat-ok'}">
|
||||
<div class="stat-val">${CR.toFixed(3)}</div>
|
||||
<div class="stat-label">CR (일관성 비율)</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted mt-2">총 불일치: ${totalMismatch} / ${decidable.length}건 (보통 제외)</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">사례별 모델 vs 직관 비교</h3>
|
||||
<div class="comparison-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>팀</th>
|
||||
<th>모델 점수</th>
|
||||
<th>모델 판단</th>
|
||||
<th>직관 (1-5)</th>
|
||||
<th>일치</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row center" style="margin-top:1rem">
|
||||
<button class="btn btn-primary btn-lg" onclick="goStep(5)">사후 설문으로 이동</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// STEP 5
|
||||
// ──────────────────────────────────────────
|
||||
function renderStep5() {
|
||||
const groups = LIKERT_QUESTIONS.map((q, qi) => {
|
||||
const opts = LIKERT_SCALE.map((label, si) => {
|
||||
const val = si + 1;
|
||||
const checked = state.post_survey[`q${qi + 1}`] === val;
|
||||
return `
|
||||
<label>
|
||||
<input type="radio" name="q${qi + 1}" value="${val}" ${checked ? 'checked' : ''} />
|
||||
<div class="likert-btn">${val}</div>
|
||||
<span class="likert-desc">${label.replace('\n', '<br>')}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="likert-group">
|
||||
<p class="likert-q">${qi + 1}. ${q}</p>
|
||||
<div class="likert-options">${opts}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<div class="card">
|
||||
<h2 class="section-title">사후 설문</h2>
|
||||
<p class="section-subtitle">마지막으로 간단한 설문에 답해주세요.</p>
|
||||
${groups}
|
||||
<div class="form-group">
|
||||
<label>모델 판단과 직관이 달랐을 때 어떤 생각이 들었나요? (선택)</label>
|
||||
<textarea class="open-textarea" id="open-resp" placeholder="자유롭게 작성해 주세요...">${escHtml(state.post_survey.open_response)}</textarea>
|
||||
</div>
|
||||
<div class="btn-row end" style="margin-top:1.5rem">
|
||||
<button class="btn btn-primary btn-lg" id="btn-submit">제출하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindStep5() {
|
||||
document
|
||||
.querySelectorAll('.likert-options input[type="radio"]')
|
||||
.forEach((inp) => {
|
||||
inp.addEventListener('change', (e) => {
|
||||
state.post_survey[e.target.name] = parseInt(e.target.value);
|
||||
});
|
||||
});
|
||||
document.getElementById('open-resp').addEventListener('input', (e) => {
|
||||
state.post_survey.open_response = e.target.value;
|
||||
});
|
||||
document.getElementById('btn-submit').addEventListener('click', () => {
|
||||
const { q1, q2, q3, q4 } = state.post_survey;
|
||||
if (!q1 || !q2 || !q3 || !q4) {
|
||||
alert(ALERT_FILL_ALL);
|
||||
return;
|
||||
}
|
||||
submitData();
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Submit & Complete
|
||||
// ──────────────────────────────────────────
|
||||
function buildPayload() {
|
||||
return {
|
||||
meta: { timestamp: state.meta.timestamp, grade: state.meta.grade },
|
||||
intuitive_judgments: state.intuitive_judgments,
|
||||
ahp: {
|
||||
pairwise_raw: state.pairwiseRaw,
|
||||
weights: state.ahpResult.weights,
|
||||
lambda_max: state.ahpResult.lambdaMax,
|
||||
CI: state.ahpResult.CI,
|
||||
CR: state.ahpResult.CR,
|
||||
cr_warning_shown: false, // CR 경고 UI 제거 — CSV 컬럼 호환용으로 유지
|
||||
},
|
||||
comparison: state.comparison,
|
||||
post_survey: state.post_survey,
|
||||
};
|
||||
}
|
||||
|
||||
async function submitData() {
|
||||
if (state.submitted) return;
|
||||
state.submitted = true;
|
||||
disableLeaveWarning();
|
||||
const payload = buildPayload();
|
||||
try {
|
||||
await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Server submit failed:', e);
|
||||
}
|
||||
state._payload = payload;
|
||||
state.step = 'complete';
|
||||
renderApp();
|
||||
}
|
||||
|
||||
function renderComplete() {
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<div class="card">
|
||||
<div class="complete-screen">
|
||||
<h2 class="complete-title">참여해 주셔서 감사합니다!</h2>
|
||||
<p class="complete-desc" style="white-space:pre-line">실험이 완료되었습니다.
|
||||
소중한 시간을 내어 참여해 주셔서 진심으로 감사드립니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindComplete() {}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Navigation & utilities
|
||||
// ──────────────────────────────────────────
|
||||
function goStep(n) {
|
||||
state.step = n;
|
||||
renderApp();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function enableLeaveWarning() {
|
||||
window._leaveHandler = (e) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = LEAVE_WARNING;
|
||||
};
|
||||
window.addEventListener('beforeunload', window._leaveHandler);
|
||||
}
|
||||
|
||||
function disableLeaveWarning() {
|
||||
if (window._leaveHandler) {
|
||||
window.removeEventListener('beforeunload', window._leaveHandler);
|
||||
window._leaveHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', renderApp);
|
||||
@@ -0,0 +1,116 @@
|
||||
const CASES = [
|
||||
{
|
||||
id: 1, industry: '헬스케어', industryEn: 'Healthcare',
|
||||
안정성: 85, 수익_가능성: 82, 자금_효율성: 80,
|
||||
사업_계획_완성도: 88, 사회적_가치: 84,
|
||||
type: 'baseline', note: '명확 선발'
|
||||
},
|
||||
{
|
||||
id: 2, industry: '물류', industryEn: 'Logistics',
|
||||
안정성: 18, 수익_가능성: 22, 자금_효율성: 15,
|
||||
사업_계획_완성도: 20, 사회적_가치: 25,
|
||||
type: 'baseline', note: '명확 탈락'
|
||||
},
|
||||
{
|
||||
id: 3, industry: '교육', industryEn: 'Education',
|
||||
안정성: 68, 수익_가능성: 72, 자금_효율성: 65,
|
||||
사업_계획_완성도: 70, 사회적_가치: 66,
|
||||
type: 'baseline', note: '무난 중상'
|
||||
},
|
||||
{
|
||||
id: 4, industry: '제조', industryEn: 'Manufacturing',
|
||||
안정성: 35, 수익_가능성: 38, 자금_효율성: 42,
|
||||
사업_계획_완성도: 30, 사회적_가치: 35,
|
||||
type: 'baseline', note: '무난 중하'
|
||||
},
|
||||
{
|
||||
id: 5, industry: '핀테크', industryEn: 'Fintech',
|
||||
안정성: 5, 수익_가능성: 68, 자금_효율성: 72,
|
||||
사업_계획_완성도: 70, 사회적_가치: 65,
|
||||
type: 'spike', spike_var: '안정성', spike_dir: 'down'
|
||||
},
|
||||
{
|
||||
id: 6, industry: '에너지', industryEn: 'Energy',
|
||||
안정성: 70, 수익_가능성: 97, 자금_효율성: 68,
|
||||
사업_계획_완성도: 65, 사회적_가치: 72,
|
||||
type: 'spike', spike_var: '수익 가능성', spike_dir: 'up'
|
||||
},
|
||||
{
|
||||
id: 7, industry: 'IT', industryEn: 'IT',
|
||||
안정성: 65, 수익_가능성: 70, 자금_효율성: 4,
|
||||
사업_계획_완성도: 68, 사회적_가치: 72,
|
||||
type: 'spike', spike_var: '자금 효율성', spike_dir: 'down'
|
||||
},
|
||||
{
|
||||
id: 8, industry: '바이오', industryEn: 'Biotech',
|
||||
안정성: 68, 수익_가능성: 65, 자금_효율성: 70,
|
||||
사업_계획_완성도: 96, 사회적_가치: 66,
|
||||
type: 'spike', spike_var: '사업 계획 완성도', spike_dir: 'up'
|
||||
},
|
||||
{
|
||||
id: 9, industry: '미디어', industryEn: 'Media',
|
||||
안정성: 72, 수익_가능성: 68, 자금_효율성: 65,
|
||||
사업_계획_완성도: 70, 사회적_가치: 6,
|
||||
type: 'spike', spike_var: '사회적 가치', spike_dir: 'down'
|
||||
},
|
||||
{
|
||||
id: 10, industry: '소셜', industryEn: 'Social',
|
||||
안정성: 95, 수익_가능성: 95, 자금_효율성: 15,
|
||||
사업_계획_완성도: 20, 사회적_가치: 25,
|
||||
type: 'conflict', note: '수익·안정성 최고 vs 나머지 최저'
|
||||
},
|
||||
{
|
||||
id: 11, industry: '복지', industryEn: 'Welfare',
|
||||
안정성: 20, 수익_가능성: 18, 자금_효율성: 22,
|
||||
사업_계획_완성도: 25, 사회적_가치: 95,
|
||||
type: 'conflict', note: '사회가치만 최고 나머지 최저'
|
||||
},
|
||||
{
|
||||
id: 12, industry: '환경', industryEn: 'Environment',
|
||||
안정성: 88, 수익_가능성: 85, 자금_효율성: 90,
|
||||
사업_계획_완성도: 92, 사회적_가치: 12,
|
||||
type: 'conflict', note: '사회가치만 최저 나머지 모두 최고'
|
||||
},
|
||||
{
|
||||
id: 13, industry: '문화', industryEn: 'Culture',
|
||||
안정성: 15, 수익_가능성: 92, 자금_효율성: 18,
|
||||
사업_계획_완성도: 20, 사회적_가치: 88,
|
||||
type: 'conflict', note: '수익·사회가치 높음 나머지 낮음'
|
||||
},
|
||||
{
|
||||
id: 14, industry: '농업', industryEn: 'Agriculture',
|
||||
안정성: 90, 수익_가능성: 22, 자금_효율성: 88,
|
||||
사업_계획_완성도: 85, 사회적_가치: 82,
|
||||
type: 'conflict', note: '수익만 최저 나머지 모두 높음'
|
||||
},
|
||||
{
|
||||
id: 15, industry: '패션', industryEn: 'Fashion',
|
||||
안정성: 48, 수익_가능성: 52, 자금_효율성: 50,
|
||||
사업_계획_완성도: 47, 사회적_가치: 53,
|
||||
type: 'conflict', note: '모든 변인 50점대 경계선'
|
||||
},
|
||||
];
|
||||
|
||||
const TEAM_LETTERS = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O'];
|
||||
|
||||
const VARS = ['안정성', '수익_가능성', '자금_효율성', '사업_계획_완성도', '사회적_가치'];
|
||||
|
||||
function shuffleCases() {
|
||||
const indices = CASES.map((_, i) => i);
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
return indices.map((origIdx, newIdx) => ({
|
||||
...CASES[origIdx],
|
||||
teamLetter: TEAM_LETTERS[newIdx],
|
||||
presentedOrder: newIdx + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function getScoreLevel(score) {
|
||||
if (score < 35) return 'low';
|
||||
if (score < 65) return 'mid';
|
||||
return 'high';
|
||||
}
|
||||
Reference in New Issue
Block a user