refine ui

This commit is contained in:
2026-08-13 21:35:38 +09:00
parent bc59c0c785
commit d87bd4111e
6 changed files with 1671 additions and 833 deletions
+3 -1
View File
@@ -5,7 +5,9 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "node server.js" "dev": "node server.js",
"build:css": "sass src/styles/main.scss public/css/main.css --no-source-map --style=expanded",
"watch:css": "sass --watch src/styles/main.scss public/css/main.css --no-source-map --style=expanded"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
+979 -1
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -12,7 +12,6 @@
<main id="app-main"></main> <main id="app-main"></main>
</div> </div>
<script src="js/i18n.js"></script>
<script src="js/cases.js"></script> <script src="js/cases.js"></script>
<script src="js/ahp.js"></script> <script src="js/ahp.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
+204 -161
View File
@@ -1,3 +1,41 @@
// ──────────────────────────────────────────
// 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 // State
// ────────────────────────────────────────── // ──────────────────────────────────────────
@@ -20,8 +58,6 @@ const state = {
sliderValues: Array(10).fill(0), sliderValues: Array(10).fill(0),
pairwiseRaw: null, pairwiseRaw: null,
ahpResult: null, ahpResult: null,
crWarningShown: false,
crWarningPending: false,
// Step 4 / 5 // Step 4 / 5
comparison: [], comparison: [],
@@ -43,8 +79,8 @@ function renderHeader() {
if (!el) return; if (!el) return;
el.innerHTML = ` el.innerHTML = `
<div class="header-inner"> <div class="header-inner">
<span class="header-title">${t('appTitle')}</span> <span class="header-title">${APP_TITLE}</span>
<button class="lang-btn" onclick="switchLang()">${t('langBtn')}</button> <span class="header-name">대구국제고 3학년 5반 이승준</span>
</div> </div>
`; `;
} }
@@ -53,12 +89,29 @@ function renderMain() {
const el = document.getElementById('app-main'); const el = document.getElementById('app-main');
if (!el) return; if (!el) return;
switch (state.step) { switch (state.step) {
case 1: el.innerHTML = renderStep1(); bindStep1(); break; case 1:
case 2: el.innerHTML = renderStep2(); bindStep2(); break; el.innerHTML = renderStep1();
case 3: el.innerHTML = renderStep3(); bindStep3(); break; bindStep1();
case 4: el.innerHTML = renderStep4(); break; break;
case 5: el.innerHTML = renderStep5(); bindStep5(); break; case 2:
case 'complete': el.innerHTML = renderComplete(); bindComplete(); break; 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;
} }
} }
@@ -67,29 +120,28 @@ function renderMain() {
// ────────────────────────────────────────── // ──────────────────────────────────────────
function renderStep1() { function renderStep1() {
const { meta } = state; const { meta } = state;
const notices = t('step1Notices').map(n => `<p>${n}</p>`).join(''); const gradeOpts = GRADE_OPTIONS.map(
const gradeOpts = t('step1Q1opts').map(o => ` (o) => `
<label class="${meta.grade === o ? 'selected' : ''}"> <label class="${meta.grade === o ? 'selected' : ''}">
<input type="radio" name="grade" value="${o}" ${meta.grade === o ? 'checked' : ''} /> <input type="radio" name="grade" value="${o}" ${meta.grade === o ? 'checked' : ''} />
${o} ${o}
</label> </label>
`).join(''); `,
).join('');
return ` return `
<div class="step-wrap"> <div class="step-wrap">
<div class="card"> <div class="card">
<h1 class="section-title">${t('step1Title')}</h1> <h1 class="section-title">스타트업 지원 심사 판단 실험</h1>
<p class="section-subtitle">${t('step1Intro')}</p> <p class="section-subtitle">당신은 스타트업 지원 프로그램의 심사위원입니다. 제기되는 질문에 따라서 성실히 답변해주시면 감사하겠습니다.<br>총 소요 시간은 10분 이내입니다.</p>
<div class="notice-box">${notices}</div>
<div class="form-group"> <div class="form-group">
<label>${t('step1Q1')}</label> <label>학년</label>
<div class="radio-group" id="rg-grade">${gradeOpts}</div> <div class="radio-group" id="rg-grade">${gradeOpts}</div>
</div> </div>
<div class="btn-row" style="margin-top:1.5rem"> <div class="btn-row" style="margin-top:1.5rem">
<button class="btn btn-primary btn-lg btn-full" id="btn-start">${t('startBtn')}</button> <button class="btn btn-primary btn-lg btn-full" id="btn-start">실험 시작</button>
</div> </div>
</div> </div>
</div> </div>
@@ -97,17 +149,26 @@ function renderStep1() {
} }
function bindStep1() { function bindStep1() {
document.getElementById('rg-grade').addEventListener('click', e => { document.getElementById('rg-grade').addEventListener('click', (e) => {
const lbl = e.target.closest('label'); const lbl = e.target.closest('label');
if (!lbl) return; if (!lbl) return;
const val = lbl.querySelector('input').value; const val = lbl.querySelector('input').value;
state.meta.grade = val; state.meta.grade = val;
document.querySelectorAll('#rg-grade label').forEach(l => document
l.classList.toggle('selected', l.querySelector('input').value === val)); .querySelectorAll('#rg-grade label')
.forEach((l) =>
l.classList.toggle(
'selected',
l.querySelector('input').value === val,
),
);
}); });
document.getElementById('btn-start').addEventListener('click', () => { document.getElementById('btn-start').addEventListener('click', () => {
if (!state.meta.grade) { alert(t('alertFillAll')); return; } if (!state.meta.grade) {
alert(ALERT_FILL_ALL);
return;
}
state.meta.timestamp = Date.now(); state.meta.timestamp = Date.now();
state.shuffledCases = shuffleCases(); state.shuffledCases = shuffleCases();
state.caseIndex = 0; state.caseIndex = 0;
@@ -125,14 +186,13 @@ function renderStep2() {
const idx = state.caseIndex; const idx = state.caseIndex;
const pct = Math.round((idx / total) * 100); const pct = Math.round((idx / total) * 100);
const c = state.shuffledCases[idx]; const c = state.shuffledCases[idx];
const varNames = t('varNames');
const varRows = VARS.map(key => { const varRows = VARS.map((key) => {
const score = c[key]; const score = c[key];
const level = getScoreLevel(score); const level = getScoreLevel(score);
return ` return `
<div class="var-row"> <div class="var-row">
<span class="var-name">${varNames[key]}</span> <span class="var-name">${VAR_LABELS[key]}</span>
<div class="var-bar-track"> <div class="var-bar-track">
<div class="var-bar-fill" data-level="${level}" style="width:${score}%"></div> <div class="var-bar-fill" data-level="${level}" style="width:${score}%"></div>
</div> </div>
@@ -141,8 +201,7 @@ function renderStep2() {
`; `;
}).join(''); }).join('');
const scale = t('judgmentScale'); const likertBtns = JUDGMENT_SCALE.map((label, i) => {
const likertBtns = scale.map((label, i) => {
const val = i + 1; const val = i + 1;
const active = state.currentJudgment === val; const active = state.currentJudgment === val;
return ` return `
@@ -155,6 +214,8 @@ function renderStep2() {
return ` return `
<div class="step-wrap"> <div class="step-wrap">
<p class="step-instruction">당신은 스타트업 지원 프로그램의 심사위원입니다. 아래 팀의 정보를 보고 지원금 지급 가능성을 직관적으로 평가해주세요.</p>
<div class="progress-bar-wrap"> <div class="progress-bar-wrap">
<div class="progress-label"> <div class="progress-label">
<span>${idx + 1} / ${total}</span> <span>${idx + 1} / ${total}</span>
@@ -172,16 +233,14 @@ function renderStep2() {
<div>${varRows}</div> <div>${varRows}</div>
<div class="judgment-section"> <div class="judgment-section">
<p class="judgment-label">${t('judgmentLabel')}</p> <p class="judgment-label">이 팀의 지원금 지급 가능성을 평가하세요</p>
<div class="judgment-likert-row" id="jl-row">${likertBtns}</div> <div class="judgment-likert-row" id="jl-row">${likertBtns}</div>
</div> </div>
<div class="btn-row end" style="margin-top:1.2rem"> <div class="btn-row end" style="margin-top:1.2rem">
<button class="btn btn-primary" id="btn-case-next" ${state.currentJudgment === null ? 'disabled' : ''}>${t('btnNext')}</button> <button class="btn btn-primary" id="btn-case-next" ${state.currentJudgment === null ? 'disabled' : ''}>다음</button>
</div> </div>
</div> </div>
<p class="text-muted text-center" style="font-size:.8rem;margin-top:.75rem">${t('step2Instruction')}</p>
</div> </div>
`; `;
} }
@@ -189,18 +248,24 @@ function renderStep2() {
function bindStep2() { function bindStep2() {
const caseStart = Date.now(); const caseStart = Date.now();
document.getElementById('jl-row').addEventListener('click', e => { document.getElementById('jl-row').addEventListener('click', (e) => {
const btn = e.target.closest('.judgment-likert-btn'); const btn = e.target.closest('.judgment-likert-btn');
if (!btn) return; if (!btn) return;
const val = parseInt(btn.dataset.val); const val = parseInt(btn.dataset.val);
state.currentJudgment = val; state.currentJudgment = val;
document.querySelectorAll('.judgment-likert-btn').forEach(b => document
b.classList.toggle('active', parseInt(b.dataset.val) === val)); .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').disabled = false;
}); });
document.getElementById('btn-case-next').addEventListener('click', () => { document.getElementById('btn-case-next').addEventListener('click', () => {
if (state.currentJudgment === null) { alert(t('alertSelectJudgment')); return; } if (state.currentJudgment === null) {
alert(ALERT_SELECT_JUDGMENT);
return;
}
const elapsed = Date.now() - caseStart; const elapsed = Date.now() - caseStart;
const c = state.shuffledCases[state.caseIndex]; const c = state.shuffledCases[state.caseIndex];
state.intuitive_judgments.push({ state.intuitive_judgments.push({
@@ -222,7 +287,7 @@ function bindStep2() {
// ────────────────────────────────────────── // ──────────────────────────────────────────
// STEP 3 // STEP 3
// ────────────────────────────────────────── // ──────────────────────────────────────────
const AHP_PAIR_LABELS_KO = [ const AHP_PAIR_LABELS = [
['안정성', '수익 가능성'], ['안정성', '수익 가능성'],
['안정성', '자금 효율성'], ['안정성', '자금 효율성'],
['안정성', '사업 계획 완성도'], ['안정성', '사업 계획 완성도'],
@@ -235,29 +300,16 @@ const AHP_PAIR_LABELS_KO = [
['사업 계획 완성도', '사회적 가치'], ['사업 계획 완성도', '사회적 가치'],
]; ];
const AHP_PAIR_LABELS_EN = [
['Stability', 'Revenue Potential'],
['Stability', 'Capital Efficiency'],
['Stability', 'Business Plan Quality'],
['Stability', 'Social Value'],
['Revenue Potential', 'Capital Efficiency'],
['Revenue Potential', 'Business Plan Quality'],
['Revenue Potential', 'Social Value'],
['Capital Efficiency', 'Business Plan Quality'],
['Capital Efficiency', 'Social Value'],
['Business Plan Quality', 'Social Value'],
];
function getPairLabels(idx) { function getPairLabels(idx) {
return currentLang === 'en' ? AHP_PAIR_LABELS_EN[idx] : AHP_PAIR_LABELS_KO[idx]; return AHP_PAIR_LABELS[idx];
} }
function getAHPDescription(sliderPos, leftLabel, rightLabel) { function getAHPDescription(sliderPos, leftLabel, rightLabel) {
if (sliderPos === 0) return t('ahpEqual'); if (sliderPos === 0) return '두 기준이 동등하게 중요합니다';
const ahpVal = Math.abs(sliderToStored(sliderPos)); // 3,5,7,9 const ahpVal = Math.abs(sliderToStored(sliderPos)); // 3,5,7,9
const level = t('ahpLevels')[ahpVal] || String(ahpVal); const level = AHP_LEVELS[ahpVal] || String(ahpVal);
if (sliderPos < 0) return t('ahpLeftMore', leftLabel, level); const crit = sliderPos < 0 ? leftLabel : rightLabel;
return t('ahpRightMore', rightLabel, level); return `${crit}이(가) ${level} 더 중요합니다`;
} }
function renderStep3() { function renderStep3() {
@@ -273,6 +325,8 @@ function renderStep3() {
return ` return `
<div class="step-wrap"> <div class="step-wrap">
<p class="step-instruction">제시되는 2개의 평가 기준 중 어느 것이 더 중요한지 비교해주세요. 왼쪽이 더 중요하면 슬라이더를 왼쪽으로, 오른쪽이 더 중요하면 오른쪽으로 움직이세요.</p>
<div class="progress-bar-wrap"> <div class="progress-bar-wrap">
<div class="progress-label"> <div class="progress-label">
<span>${idx + 1} / ${total}</span> <span>${idx + 1} / ${total}</span>
@@ -283,8 +337,7 @@ function renderStep3() {
</div> </div>
<div class="card"> <div class="card">
<h2 class="section-title" style="font-size:1.1rem">${t('step3Title')}</h2> <h2 class="section-title" style="font-size:1.1rem">AHP 쌍대비교</h2>
<p class="section-subtitle">${t('step3Instruction')}</p>
<div class="ahp-pair-card"> <div class="ahp-pair-card">
<div class="pair-criteria"> <div class="pair-criteria">
@@ -306,19 +359,9 @@ function renderStep3() {
<div class="ahp-description" id="ahp-desc">${desc}</div> <div class="ahp-description" id="ahp-desc">${desc}</div>
</div> </div>
${state.crWarningPending ? `
<div class="cr-warning">
<p>${t('crWarning')}</p>
<div class="btn-row">
<button class="btn btn-outline" id="btn-redo">${t('btnRedo')}</button>
<button class="btn btn-gray" id="btn-cr-continue">${t('btnContinue')}</button>
</div>
</div>
` : `
<div class="btn-row end" style="margin-top:1.2rem"> <div class="btn-row end" style="margin-top:1.2rem">
<button class="btn btn-primary" id="btn-pair-next">${t('btnNext')}</button> <button class="btn btn-primary" id="btn-pair-next">다음</button>
</div> </div>
`}
</div> </div>
</div> </div>
`; `;
@@ -330,32 +373,23 @@ function bindStep3() {
const slider = document.getElementById('ahp-slider'); const slider = document.getElementById('ahp-slider');
if (slider) { if (slider) {
slider.addEventListener('input', e => { slider.addEventListener('input', (e) => {
const val = parseInt(e.target.value); const val = parseInt(e.target.value);
state.sliderValues[idx] = val; state.sliderValues[idx] = val;
document.getElementById('ahp-desc').textContent = getAHPDescription(val, left, right); document.getElementById('ahp-desc').textContent = getAHPDescription(
document.getElementById('crit-left').className = `crit crit-left ${val < 0 ? 'crit-active-left' : ''}`; val,
document.getElementById('crit-right').className = `crit crit-right ${val > 0 ? 'crit-active-right' : ''}`; 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'); const nextBtn = document.getElementById('btn-pair-next');
if (nextBtn) nextBtn.addEventListener('click', advancePair); if (nextBtn) nextBtn.addEventListener('click', advancePair);
const redoBtn = document.getElementById('btn-redo');
if (redoBtn) redoBtn.addEventListener('click', () => {
state.crWarningPending = false;
state.pairIndex = 0;
state.sliderValues = Array(10).fill(0);
renderMain();
});
const contBtn = document.getElementById('btn-cr-continue');
if (contBtn) contBtn.addEventListener('click', () => {
state.crWarningPending = false;
finalizeAHP();
goStep(4);
});
} }
function advancePair() { function advancePair() {
@@ -364,20 +398,13 @@ function advancePair() {
state.pairIndex++; state.pairIndex++;
renderMain(); renderMain();
} else { } else {
const raw = state.sliderValues.map(s => sliderToStored(s)); const raw = state.sliderValues.map((s) => sliderToStored(s));
state.pairwiseRaw = raw; state.pairwiseRaw = raw;
const result = runAHP(raw); // CR은 분석용으로 계속 기록하되, 모순이 있어도 재입력 없이 그대로 진행
state.ahpResult = result; state.ahpResult = runAHP(raw);
if (result.CR > 0.10) {
state.crWarningPending = true;
state.crWarningShown = true;
renderMain();
} else {
finalizeAHP(); finalizeAHP();
goStep(4); goStep(4);
} }
}
} }
// Convert Likert 1-5 to judgment category for comparison with model // Convert Likert 1-5 to judgment category for comparison with model
@@ -393,9 +420,14 @@ function finalizeAHP() {
const modelScore = calcModelScore(c, weights); const modelScore = calcModelScore(c, weights);
const modelJudgment = modelScore >= 50 ? 'select' : 'reject'; const modelJudgment = modelScore >= 50 ? 'select' : 'reject';
const rawLikert = state.intuitive_judgments[i]?.judgment ?? null; const rawLikert = state.intuitive_judgments[i]?.judgment ?? null;
const intuitiveCategory = rawLikert !== null ? likertToJudgment(rawLikert) : null; const intuitiveCategory =
const match = intuitiveCategory === 'neutral' ? null rawLikert !== null ? likertToJudgment(rawLikert) : null;
: intuitiveCategory === modelJudgment ? true : false; const match =
intuitiveCategory === 'neutral'
? null
: intuitiveCategory === modelJudgment
? true
: false;
return { return {
case_id: c.id, case_id: c.id,
team: c.teamLetter, team: c.teamLetter,
@@ -414,13 +446,12 @@ function finalizeAHP() {
// ────────────────────────────────────────── // ──────────────────────────────────────────
function renderStep4() { function renderStep4() {
const weights = state.ahpResult.weights; const weights = state.ahpResult.weights;
const varNames = t('varNames');
const weightRows = VARS.map((key, i) => { const weightRows = VARS.map((key, i) => {
const pct = Math.round(weights[i] * 100); const pct = Math.round(weights[i] * 100);
return ` return `
<div class="wc-row"> <div class="wc-row">
<span class="wc-label">${varNames[key]}</span> <span class="wc-label">${VAR_LABELS[key]}</span>
<div class="wc-track"><div class="wc-fill" style="width:${pct}%"></div></div> <div class="wc-track"><div class="wc-fill" style="width:${pct}%"></div></div>
<span class="wc-pct">${pct}%</span> <span class="wc-pct">${pct}%</span>
</div> </div>
@@ -429,29 +460,33 @@ function renderStep4() {
const comp = state.comparison; const comp = state.comparison;
// Exclude neutral (match===null) from mismatch counts // Exclude neutral (match===null) from mismatch counts
const decidable = comp.filter(c => c.match !== null); const decidable = comp.filter((c) => c.match !== null);
const totalMismatch = decidable.filter(c => !c.match).length; const totalMismatch = decidable.filter((c) => !c.match).length;
const baseline = decidable.filter(c => c.type === 'baseline'); const baseline = decidable.filter((c) => c.type === 'baseline');
const spike = decidable.filter(c => c.type === 'spike'); const spike = decidable.filter((c) => c.type === 'spike');
const conflict = decidable.filter(c => c.type === 'conflict'); const conflict = decidable.filter((c) => c.type === 'conflict');
const { CR } = state.ahpResult; const { CR } = state.ahpResult;
const rows = comp.map(c => { const rows = comp
.map((c) => {
const mis = c.match === false; const mis = c.match === false;
const neu = c.match === null; const neu = c.match === null;
const mLabel = c.model_judgment === 'select' const mLabel =
? `<span class="badge badge-select">${t('select')}</span>` c.model_judgment === 'select'
: `<span class="badge badge-reject">${t('reject')}</span>`; ? `<span class="badge badge-select">선발</span>`
const iLabel = c.intuitive_category === 'select' : `<span class="badge badge-reject">탈락</span>`;
const iLabel =
c.intuitive_category === 'select'
? `<span class="badge badge-select">${c.intuitive_likert}</span>` ? `<span class="badge badge-select">${c.intuitive_likert}</span>`
: c.intuitive_category === 'reject' : c.intuitive_category === 'reject'
? `<span class="badge badge-reject">${c.intuitive_likert}</span>` ? `<span class="badge badge-reject">${c.intuitive_likert}</span>`
: `<span class="badge badge-neutral">${c.intuitive_likert}</span>`; : `<span class="badge badge-neutral">${c.intuitive_likert}</span>`;
const matchBadge = c.match === true const matchBadge =
? `<span class="badge badge-match">${t('colYes')}</span>` c.match === true
? `<span class="badge badge-match">✓</span>`
: c.match === false : c.match === false
? `<span class="badge badge-mismatch">${t('colNo')}</span>` ? `<span class="badge badge-mismatch"></span>`
: `<span class="badge badge-neutral">${t('colNeutral')}</span>`; : `<span class="badge badge-neutral"></span>`;
return ` return `
<tr class="${mis ? 'mismatch' : neu ? 'neutral-row' : ''}"> <tr class="${mis ? 'mismatch' : neu ? 'neutral-row' : ''}">
<td>Team ${c.team}</td> <td>Team ${c.team}</td>
@@ -461,54 +496,55 @@ function renderStep4() {
<td>${matchBadge}</td> <td>${matchBadge}</td>
</tr> </tr>
`; `;
}).join(''); })
.join('');
return ` return `
<div class="step-wrap"> <div class="step-wrap">
<div class="card"> <div class="card">
<h2 class="section-title">${t('step4Title')}</h2> <h2 class="section-title">결과 비교</h2>
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.75rem">${t('weightsTitle')}</h3> <h3 style="font-size:.95rem;font-weight:700;margin-bottom:.75rem">나의 판단 기준 (AHP 가중치)</h3>
<div class="weight-chart">${weightRows}</div> <div class="weight-chart">${weightRows}</div>
</div> </div>
<div class="card"> <div class="card">
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">${t('statsTitle')}</h3> <h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">핵심 통계</h3>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-box ${totalMismatch > 7 ? 'stat-warn' : 'stat-ok'}"> <div class="stat-box ${totalMismatch > 7 ? 'stat-warn' : 'stat-ok'}">
<div class="stat-val">${totalMismatch}/${decidable.length}</div> <div class="stat-val">${totalMismatch}/${decidable.length}</div>
<div class="stat-label">${t('statTotal')}</div> <div class="stat-label">전체 불일치율</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="stat-val">${baseline.filter(c=>!c.match).length}/${baseline.length}</div> <div class="stat-val">${baseline.filter((c) => !c.match).length}/${baseline.length}</div>
<div class="stat-label">${t('statBaseline')}</div> <div class="stat-label">베이스라인<br>불일치율</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="stat-val">${spike.filter(c=>!c.match).length}/${spike.length}</div> <div class="stat-val">${spike.filter((c) => !c.match).length}/${spike.length}</div>
<div class="stat-label">${t('statSpike')}</div> <div class="stat-label">스파이크 사례<br>불일치율</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="stat-val">${conflict.filter(c=>!c.match).length}/${conflict.length}</div> <div class="stat-val">${conflict.filter((c) => !c.match).length}/${conflict.length}</div>
<div class="stat-label">${t('statConflict')}</div> <div class="stat-label">충돌 사례<br>불일치율</div>
</div> </div>
<div class="stat-box ${CR > 0.10 ? 'stat-warn' : 'stat-ok'}"> <div class="stat-box ${CR > 0.1 ? 'stat-warn' : 'stat-ok'}">
<div class="stat-val">${CR.toFixed(3)}</div> <div class="stat-val">${CR.toFixed(3)}</div>
<div class="stat-label">${t('statCR')}</div> <div class="stat-label">CR (일관성 비율)</div>
</div> </div>
</div> </div>
<p class="text-muted mt-2">${t('totalMismatch', totalMismatch, decidable.length)}</p> <p class="text-muted mt-2">총 불일치: ${totalMismatch} / ${decidable.length}건 (보통 제외)</p>
</div> </div>
<div class="card"> <div class="card">
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">${t('tableTitle')}</h3> <h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">사례별 모델 vs 직관 비교</h3>
<div class="comparison-table-wrap"> <div class="comparison-table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>${t('colTeam')}</th> <th></th>
<th>${t('colModelScore')}</th> <th>모델 점수</th>
<th>${t('colModelJudge')}</th> <th>모델 판단</th>
<th>${t('colIntuition')}</th> <th>직관 (1-5)</th>
<th>${t('colMatch')}</th> <th>일치</th>
</tr> </tr>
</thead> </thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
@@ -517,7 +553,7 @@ function renderStep4() {
</div> </div>
<div class="btn-row center" style="margin-top:1rem"> <div class="btn-row center" style="margin-top:1rem">
<button class="btn btn-primary btn-lg" onclick="goStep(5)">${t('btnGoSurvey')}</button> <button class="btn btn-primary btn-lg" onclick="goStep(5)">사후 설문으로 이동</button>
</div> </div>
</div> </div>
`; `;
@@ -527,16 +563,13 @@ function renderStep4() {
// STEP 5 // STEP 5
// ────────────────────────────────────────── // ──────────────────────────────────────────
function renderStep5() { function renderStep5() {
const qs = t('likertQ'); const groups = LIKERT_QUESTIONS.map((q, qi) => {
const scale = t('likertScale'); const opts = LIKERT_SCALE.map((label, si) => {
const groups = qs.map((q, qi) => {
const opts = scale.map((label, si) => {
const val = si + 1; const val = si + 1;
const checked = state.post_survey[`q${qi+1}`] === val; const checked = state.post_survey[`q${qi + 1}`] === val;
return ` return `
<label> <label>
<input type="radio" name="q${qi+1}" value="${val}" ${checked ? 'checked' : ''} /> <input type="radio" name="q${qi + 1}" value="${val}" ${checked ? 'checked' : ''} />
<div class="likert-btn">${val}</div> <div class="likert-btn">${val}</div>
<span class="likert-desc">${label.replace('\n', '<br>')}</span> <span class="likert-desc">${label.replace('\n', '<br>')}</span>
</label> </label>
@@ -544,7 +577,7 @@ function renderStep5() {
}).join(''); }).join('');
return ` return `
<div class="likert-group"> <div class="likert-group">
<p class="likert-q">${qi+1}. ${q}</p> <p class="likert-q">${qi + 1}. ${q}</p>
<div class="likert-options">${opts}</div> <div class="likert-options">${opts}</div>
</div> </div>
`; `;
@@ -553,15 +586,15 @@ function renderStep5() {
return ` return `
<div class="step-wrap"> <div class="step-wrap">
<div class="card"> <div class="card">
<h2 class="section-title">${t('step5Title')}</h2> <h2 class="section-title">사후 설문</h2>
<p class="section-subtitle">${t('step5Desc')}</p> <p class="section-subtitle">마지막으로 간단한 설문에 답해주세요.</p>
${groups} ${groups}
<div class="form-group"> <div class="form-group">
<label>${t('openQ')}</label> <label>모델 판단과 직관이 달랐을 때 어떤 생각이 들었나요? (선택)</label>
<textarea class="open-textarea" id="open-resp" placeholder="${t('openPh')}">${escHtml(state.post_survey.open_response)}</textarea> <textarea class="open-textarea" id="open-resp" placeholder="자유롭게 작성해 주세요...">${escHtml(state.post_survey.open_response)}</textarea>
</div> </div>
<div class="btn-row end" style="margin-top:1.5rem"> <div class="btn-row end" style="margin-top:1.5rem">
<button class="btn btn-primary btn-lg" id="btn-submit">${t('btnSubmit')}</button> <button class="btn btn-primary btn-lg" id="btn-submit">제출하기</button>
</div> </div>
</div> </div>
</div> </div>
@@ -569,17 +602,22 @@ function renderStep5() {
} }
function bindStep5() { function bindStep5() {
document.querySelectorAll('.likert-options input[type="radio"]').forEach(inp => { document
inp.addEventListener('change', e => { .querySelectorAll('.likert-options input[type="radio"]')
.forEach((inp) => {
inp.addEventListener('change', (e) => {
state.post_survey[e.target.name] = parseInt(e.target.value); state.post_survey[e.target.name] = parseInt(e.target.value);
}); });
}); });
document.getElementById('open-resp').addEventListener('input', e => { document.getElementById('open-resp').addEventListener('input', (e) => {
state.post_survey.open_response = e.target.value; state.post_survey.open_response = e.target.value;
}); });
document.getElementById('btn-submit').addEventListener('click', () => { document.getElementById('btn-submit').addEventListener('click', () => {
const { q1, q2, q3, q4 } = state.post_survey; const { q1, q2, q3, q4 } = state.post_survey;
if (!q1 || !q2 || !q3 || !q4) { alert(t('alertFillAll')); return; } if (!q1 || !q2 || !q3 || !q4) {
alert(ALERT_FILL_ALL);
return;
}
submitData(); submitData();
}); });
} }
@@ -597,7 +635,7 @@ function buildPayload() {
lambda_max: state.ahpResult.lambdaMax, lambda_max: state.ahpResult.lambdaMax,
CI: state.ahpResult.CI, CI: state.ahpResult.CI,
CR: state.ahpResult.CR, CR: state.ahpResult.CR,
cr_warning_shown: state.crWarningShown, cr_warning_shown: false, // CR 경고 UI 제거 — CSV 컬럼 호환용으로 유지
}, },
comparison: state.comparison, comparison: state.comparison,
post_survey: state.post_survey, post_survey: state.post_survey,
@@ -628,9 +666,9 @@ function renderComplete() {
<div class="step-wrap"> <div class="step-wrap">
<div class="card"> <div class="card">
<div class="complete-screen"> <div class="complete-screen">
<div class="complete-icon">🎉</div> <h2 class="complete-title">참여해 주셔서 감사합니다!</h2>
<h2 class="complete-title">${t('completeTitle')}</h2> <p class="complete-desc" style="white-space:pre-line">실험이 완료되었습니다.
<p class="complete-desc" style="white-space:pre-line">${t('completeDesc')}</p> 소중한 시간을 내어 참여해 주셔서 진심으로 감사드립니다.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -649,7 +687,10 @@ function goStep(n) {
} }
function enableLeaveWarning() { function enableLeaveWarning() {
window._leaveHandler = e => { e.preventDefault(); e.returnValue = t('leaveWarning'); }; window._leaveHandler = (e) => {
e.preventDefault();
e.returnValue = LEAVE_WARNING;
};
window.addEventListener('beforeunload', window._leaveHandler); window.addEventListener('beforeunload', window._leaveHandler);
} }
@@ -662,8 +703,10 @@ function disableLeaveWarning() {
function escHtml(str) { function escHtml(str) {
return String(str) return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;') .replace(/&/g, '&amp;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;'); .replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
} }
document.addEventListener('DOMContentLoaded', renderApp); document.addEventListener('DOMContentLoaded', renderApp);
-187
View File
@@ -1,187 +0,0 @@
const I18N = {
ko: {
appTitle: '스타트업 지원 심사 판단 실험',
langBtn: 'EN',
stepLabels: ['안내', '직관 판단', 'AHP 비교', '결과', '사후 설문'],
// Step 1
step1Title: '스타트업 지원 심사 판단 실험',
step1Intro: '당신은 스타트업 지원 프로그램의 심사위원입니다. 이 실험은 두 가지 판단 방식 — 인간의 직관과 AI 기반 의사결정 모델(AHP) — 을 비교 분석합니다. 15개의 스타트업 사례를 보고 지원금 지급 가능성을 평가한 뒤, 본인만의 평가 기준 가중치를 설정하게 됩니다.',
step1Notices: [
'정답은 없습니다. 느낌과 직관대로 판단해 주세요.',
'총 소요 시간은 약 20~25분입니다.',
'중간에 페이지를 나가면 데이터가 저장되지 않습니다.',
],
step1Q1: '학년',
step1Q1opts: ['1학년', '2학년', '3학년'],
startBtn: '실험 시작',
// Step 2
step2Instruction: '당신은 스타트업 지원 프로그램의 심사위원입니다. 아래 팀의 정보를 보고 지원금 지급 가능성을 직관적으로 평가하세요. 계산하지 말고 느낌대로 판단하세요.',
varNames: {
'안정성': '안정성',
'수익_가능성': '수익 가능성',
'자금_효율성': '자금 효율성',
'사업_계획_완성도': '사업 계획 완성도',
'사회적_가치': '사회적 가치',
},
judgmentLabel: '이 팀의 지원금 지급 가능성을 평가하세요',
judgmentScale: ['매우\n탈락', '탈락', '보통', '선발', '매우\n선발'],
btnNext: '다음',
// Step 3
step3Title: 'AHP 쌍대비교',
step3Instruction: '5개의 평가 기준 중 어느 것이 더 중요한지 비교해주세요. 왼쪽이 더 중요하면 슬라이더를 왼쪽으로, 오른쪽이 더 중요하면 오른쪽으로 움직이세요.',
ahpEqual: '두 기준이 동등하게 중요합니다',
ahpLeftMore: (crit, level) => `${crit}이(가) ${level} 더 중요합니다`,
ahpRightMore: (crit, level) => `${crit}이(가) ${level} 더 중요합니다`,
ahpLevels: { 3: '약간', 5: '꽤', 7: '매우', 9: '극히' },
crWarning: '판단에 약간의 모순이 감지되었습니다(CR > 0.10). 다시 입력하시겠습니까?',
btnRedo: '다시 입력',
btnContinue: '그대로 진행',
// Step 4
step4Title: '결과 비교',
weightsTitle: '나의 판단 기준 (AHP 가중치)',
tableTitle: '사례별 모델 vs 직관 비교',
colTeam: '팀',
colModelScore: '모델 점수',
colModelJudge: '모델 판단',
colIntuition: '직관 (1-5)',
colMatch: '일치',
colYes: '✓',
colNo: '✗',
colNeutral: '△',
totalMismatch: (n, t) => `총 불일치: ${n} / ${t}건 (보통 제외)`,
statsTitle: '핵심 통계',
statTotal: '전체 불일치율',
statBaseline: '베이스라인\n불일치율',
statSpike: '스파이크 사례\n불일치율',
statConflict: '충돌 사례\n불일치율',
statCR: 'CR (일관성 비율)',
select: '선발',
reject: '탈락',
neutral: '보통',
btnGoSurvey: '사후 설문으로 이동',
// Step 5
step5Title: '사후 설문',
step5Desc: '마지막으로 간단한 설문에 답해주세요.',
likertQ: [
'모델의 판단 결과를 보고 놀라웠다.',
'내 직관보다 모델의 판단이 더 합리적이라고 생각한다.',
'이 선발 결정을 모델에게 맡겨도 괜찮다고 생각한다.',
'이번 판단은 온전히 내 의지로 한 것이라고 느낀다.',
],
likertScale: ['전혀\n아니다', '아니다', '보통', '그렇다', '매우\n그렇다'],
openQ: '모델 판단과 직관이 달랐을 때 어떤 생각이 들었나요? (선택)',
openPh: '자유롭게 작성해 주세요...',
btnSubmit: '제출하기',
// Complete
completeTitle: '참여해 주셔서 감사합니다!',
completeDesc: '실험이 완료되었습니다.\n소중한 시간을 내어 참여해 주셔서 진심으로 감사드립니다.\n응답 데이터는 안전하게 저장되었습니다.',
alertFillAll: '모든 항목을 입력해 주세요.',
alertSelectJudgment: '먼저 판단을 선택해 주세요.',
leaveWarning: '실험이 완료되지 않았습니다. 나가시겠습니까?',
},
en: {
appTitle: 'Startup Funding Evaluation Experiment',
langBtn: '한국어',
stepLabels: ['Intro', 'Intuition', 'AHP', 'Results', 'Survey'],
step1Title: 'Startup Funding Evaluation Experiment',
step1Intro: 'You are a judge in a startup support program. This experiment compares two decision-making approaches — human intuition and an AI-based model (AHP). You will evaluate 15 startup cases and then set your own weighting criteria.',
step1Notices: [
'There are no right or wrong answers. Judge by your gut feeling.',
'The experiment takes approximately 2025 minutes.',
'Leaving the page mid-experiment will result in lost data.',
],
step1Q1: 'Grade',
step1Q1opts: ['Grade 1', 'Grade 2', 'Grade 3'],
startBtn: 'Start Experiment',
step2Instruction: 'You are a judge in a startup support program. Based on the team\'s information below, intuitively evaluate their funding potential. Do not calculate — go with your gut.',
varNames: {
'안정성': 'Stability',
'수익_가능성': 'Revenue Potential',
'자금_효율성': 'Capital Efficiency',
'사업_계획_완성도': 'Business Plan Quality',
'사회적_가치': 'Social Value',
},
judgmentLabel: 'Evaluate this team\'s funding potential',
judgmentScale: ['Strongly\nReject', 'Reject', 'Neutral', 'Fund', 'Strongly\nFund'],
btnNext: 'Next',
step3Title: 'AHP Pairwise Comparison',
step3Instruction: 'Compare which of the 5 criteria matters more. Move the slider left if the left criterion is more important, right if the right one is.',
ahpEqual: 'Both criteria are equally important',
ahpLeftMore: (crit, level) => `${crit} is ${level} more important`,
ahpRightMore: (crit, level) => `${crit} is ${level} more important`,
ahpLevels: { 3: 'somewhat', 5: 'considerably', 7: 'much', 9: 'extremely' },
crWarning: 'Some inconsistency detected (CR > 0.10). Would you like to redo the comparison?',
btnRedo: 'Redo',
btnContinue: 'Continue anyway',
step4Title: 'Results Comparison',
weightsTitle: 'Your Judgment Criteria (AHP Weights)',
tableTitle: 'Model vs. Intuition per Case',
colTeam: 'Team',
colModelScore: 'Model Score',
colModelJudge: 'Model',
colIntuition: 'Intuition (1-5)',
colMatch: 'Match',
colYes: '✓',
colNo: '✗',
colNeutral: '△',
totalMismatch: (n, tot) => `Total mismatches: ${n} / ${tot} (neutral excluded)`,
statsTitle: 'Key Statistics',
statTotal: 'Overall Mismatch Rate',
statBaseline: 'Baseline\nMismatch Rate',
statSpike: 'Spike Case\nMismatch Rate',
statConflict: 'Conflict Case\nMismatch Rate',
statCR: 'CR (Consistency Ratio)',
select: 'Fund',
reject: 'Reject',
neutral: 'Neutral',
btnGoSurvey: 'Go to Post-Survey',
step5Title: 'Post-Survey',
step5Desc: 'Please answer a few final questions.',
likertQ: [
'I was surprised by the model\'s judgment.',
'I think the model\'s judgment is more rational than my intuition.',
'I would be comfortable letting the model make these selection decisions.',
'I feel the decisions I made were entirely my own.',
],
likertScale: ['Strongly\nDisagree', 'Disagree', 'Neutral', 'Agree', 'Strongly\nAgree'],
openQ: 'What did you think when your intuition differed from the model? (Optional)',
openPh: 'Write freely...',
btnSubmit: 'Submit',
completeTitle: 'Thank you for participating!',
completeDesc: 'The experiment is complete.\nThank you sincerely for your time and participation.\nYour responses have been saved securely.',
alertFillAll: 'Please fill in all fields.',
alertSelectJudgment: 'Please make a judgment first.',
leaveWarning: 'The experiment is not complete. Are you sure you want to leave?',
},
};
let currentLang = 'ko';
function t(key, ...args) {
const val = I18N[currentLang][key];
if (typeof val === 'function') return val(...args);
return val ?? key;
}
function switchLang() {
currentLang = currentLang === 'ko' ? 'en' : 'ko';
document.documentElement.lang = currentLang;
if (typeof renderApp === 'function') renderApp();
}
+277 -274
View File
File diff suppressed because it is too large Load Diff