refine ui
This commit is contained in:
+3
-1
@@ -5,7 +5,9 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"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": {
|
||||
"express": "^4.18.2",
|
||||
|
||||
+979
-1
File diff suppressed because one or more lines are too long
@@ -12,7 +12,6 @@
|
||||
<main id="app-main"></main>
|
||||
</div>
|
||||
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/cases.js"></script>
|
||||
<script src="js/ahp.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
+201
-158
@@ -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
|
||||
// ──────────────────────────────────────────
|
||||
@@ -20,8 +58,6 @@ const state = {
|
||||
sliderValues: Array(10).fill(0),
|
||||
pairwiseRaw: null,
|
||||
ahpResult: null,
|
||||
crWarningShown: false,
|
||||
crWarningPending: false,
|
||||
|
||||
// Step 4 / 5
|
||||
comparison: [],
|
||||
@@ -43,8 +79,8 @@ function renderHeader() {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="header-inner">
|
||||
<span class="header-title">${t('appTitle')}</span>
|
||||
<button class="lang-btn" onclick="switchLang()">${t('langBtn')}</button>
|
||||
<span class="header-title">${APP_TITLE}</span>
|
||||
<span class="header-name">대구국제고 3학년 5반 이승준</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -53,12 +89,29 @@ 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,29 +120,28 @@ function renderMain() {
|
||||
// ──────────────────────────────────────────
|
||||
function renderStep1() {
|
||||
const { meta } = state;
|
||||
const notices = t('step1Notices').map(n => `<p>${n}</p>`).join('');
|
||||
const gradeOpts = t('step1Q1opts').map(o => `
|
||||
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('');
|
||||
`,
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<div class="card">
|
||||
<h1 class="section-title">${t('step1Title')}</h1>
|
||||
<p class="section-subtitle">${t('step1Intro')}</p>
|
||||
|
||||
<div class="notice-box">${notices}</div>
|
||||
<h1 class="section-title">스타트업 지원 심사 판단 실험</h1>
|
||||
<p class="section-subtitle">당신은 스타트업 지원 프로그램의 심사위원입니다. 제기되는 질문에 따라서 성실히 답변해주시면 감사하겠습니다.<br>총 소요 시간은 10분 이내입니다.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t('step1Q1')}</label>
|
||||
<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">${t('startBtn')}</button>
|
||||
<button class="btn btn-primary btn-lg btn-full" id="btn-start">실험 시작</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,17 +149,26 @@ function renderStep1() {
|
||||
}
|
||||
|
||||
function bindStep1() {
|
||||
document.getElementById('rg-grade').addEventListener('click', e => {
|
||||
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
|
||||
.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(t('alertFillAll')); return; }
|
||||
if (!state.meta.grade) {
|
||||
alert(ALERT_FILL_ALL);
|
||||
return;
|
||||
}
|
||||
state.meta.timestamp = Date.now();
|
||||
state.shuffledCases = shuffleCases();
|
||||
state.caseIndex = 0;
|
||||
@@ -125,14 +186,13 @@ function renderStep2() {
|
||||
const idx = state.caseIndex;
|
||||
const pct = Math.round((idx / total) * 100);
|
||||
const c = state.shuffledCases[idx];
|
||||
const varNames = t('varNames');
|
||||
|
||||
const varRows = VARS.map(key => {
|
||||
const varRows = VARS.map((key) => {
|
||||
const score = c[key];
|
||||
const level = getScoreLevel(score);
|
||||
return `
|
||||
<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-fill" data-level="${level}" style="width:${score}%"></div>
|
||||
</div>
|
||||
@@ -141,8 +201,7 @@ function renderStep2() {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const scale = t('judgmentScale');
|
||||
const likertBtns = scale.map((label, i) => {
|
||||
const likertBtns = JUDGMENT_SCALE.map((label, i) => {
|
||||
const val = i + 1;
|
||||
const active = state.currentJudgment === val;
|
||||
return `
|
||||
@@ -155,6 +214,8 @@ function renderStep2() {
|
||||
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<p class="step-instruction">당신은 스타트업 지원 프로그램의 심사위원입니다. 아래 팀의 정보를 보고 지원금 지급 가능성을 직관적으로 평가해주세요.</p>
|
||||
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-label">
|
||||
<span>${idx + 1} / ${total}</span>
|
||||
@@ -172,16 +233,14 @@ function renderStep2() {
|
||||
<div>${varRows}</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<p class="text-muted text-center" style="font-size:.8rem;margin-top:.75rem">${t('step2Instruction')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -189,18 +248,24 @@ function renderStep2() {
|
||||
function bindStep2() {
|
||||
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');
|
||||
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
|
||||
.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(t('alertSelectJudgment')); return; }
|
||||
if (state.currentJudgment === null) {
|
||||
alert(ALERT_SELECT_JUDGMENT);
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - caseStart;
|
||||
const c = state.shuffledCases[state.caseIndex];
|
||||
state.intuitive_judgments.push({
|
||||
@@ -222,7 +287,7 @@ function bindStep2() {
|
||||
// ──────────────────────────────────────────
|
||||
// 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) {
|
||||
return currentLang === 'en' ? AHP_PAIR_LABELS_EN[idx] : AHP_PAIR_LABELS_KO[idx];
|
||||
return AHP_PAIR_LABELS[idx];
|
||||
}
|
||||
|
||||
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 level = t('ahpLevels')[ahpVal] || String(ahpVal);
|
||||
if (sliderPos < 0) return t('ahpLeftMore', leftLabel, level);
|
||||
return t('ahpRightMore', rightLabel, level);
|
||||
const level = AHP_LEVELS[ahpVal] || String(ahpVal);
|
||||
const crit = sliderPos < 0 ? leftLabel : rightLabel;
|
||||
return `${crit}이(가) ${level} 더 중요합니다`;
|
||||
}
|
||||
|
||||
function renderStep3() {
|
||||
@@ -273,6 +325,8 @@ function renderStep3() {
|
||||
|
||||
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>
|
||||
@@ -283,8 +337,7 @@ function renderStep3() {
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="font-size:1.1rem">${t('step3Title')}</h2>
|
||||
<p class="section-subtitle">${t('step3Instruction')}</p>
|
||||
<h2 class="section-title" style="font-size:1.1rem">AHP 쌍대비교</h2>
|
||||
|
||||
<div class="ahp-pair-card">
|
||||
<div class="pair-criteria">
|
||||
@@ -306,19 +359,9 @@ function renderStep3() {
|
||||
<div class="ahp-description" id="ahp-desc">${desc}</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">
|
||||
<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>
|
||||
`;
|
||||
@@ -330,32 +373,23 @@ function bindStep3() {
|
||||
|
||||
const slider = document.getElementById('ahp-slider');
|
||||
if (slider) {
|
||||
slider.addEventListener('input', e => {
|
||||
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' : ''}`;
|
||||
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);
|
||||
|
||||
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() {
|
||||
@@ -364,21 +398,14 @@ function advancePair() {
|
||||
state.pairIndex++;
|
||||
renderMain();
|
||||
} else {
|
||||
const raw = state.sliderValues.map(s => sliderToStored(s));
|
||||
const raw = state.sliderValues.map((s) => sliderToStored(s));
|
||||
state.pairwiseRaw = raw;
|
||||
const result = runAHP(raw);
|
||||
state.ahpResult = result;
|
||||
|
||||
if (result.CR > 0.10) {
|
||||
state.crWarningPending = true;
|
||||
state.crWarningShown = true;
|
||||
renderMain();
|
||||
} else {
|
||||
// CR은 분석용으로 계속 기록하되, 모순이 있어도 재입력 없이 그대로 진행
|
||||
state.ahpResult = runAHP(raw);
|
||||
finalizeAHP();
|
||||
goStep(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Likert 1-5 to judgment category for comparison with model
|
||||
function likertToJudgment(likert) {
|
||||
@@ -393,9 +420,14 @@ function finalizeAHP() {
|
||||
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;
|
||||
const intuitiveCategory =
|
||||
rawLikert !== null ? likertToJudgment(rawLikert) : null;
|
||||
const match =
|
||||
intuitiveCategory === 'neutral'
|
||||
? null
|
||||
: intuitiveCategory === modelJudgment
|
||||
? true
|
||||
: false;
|
||||
return {
|
||||
case_id: c.id,
|
||||
team: c.teamLetter,
|
||||
@@ -414,13 +446,12 @@ function finalizeAHP() {
|
||||
// ──────────────────────────────────────────
|
||||
function renderStep4() {
|
||||
const weights = state.ahpResult.weights;
|
||||
const varNames = t('varNames');
|
||||
|
||||
const weightRows = VARS.map((key, i) => {
|
||||
const pct = Math.round(weights[i] * 100);
|
||||
return `
|
||||
<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>
|
||||
<span class="wc-pct">${pct}%</span>
|
||||
</div>
|
||||
@@ -429,29 +460,33 @@ function renderStep4() {
|
||||
|
||||
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 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 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">${t('select')}</span>`
|
||||
: `<span class="badge badge-reject">${t('reject')}</span>`;
|
||||
const iLabel = c.intuitive_category === 'select'
|
||||
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">${t('colYes')}</span>`
|
||||
const matchBadge =
|
||||
c.match === true
|
||||
? `<span class="badge badge-match">✓</span>`
|
||||
: c.match === false
|
||||
? `<span class="badge badge-mismatch">${t('colNo')}</span>`
|
||||
: `<span class="badge badge-neutral">${t('colNeutral')}</span>`;
|
||||
? `<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>
|
||||
@@ -461,54 +496,55 @@ function renderStep4() {
|
||||
<td>${matchBadge}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<div class="card">
|
||||
<h2 class="section-title">${t('step4Title')}</h2>
|
||||
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.75rem">${t('weightsTitle')}</h3>
|
||||
<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">${t('statsTitle')}</h3>
|
||||
<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">${t('statTotal')}</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">${t('statBaseline')}</div>
|
||||
<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">${t('statSpike')}</div>
|
||||
<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">${t('statConflict')}</div>
|
||||
<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.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-label">${t('statCR')}</div>
|
||||
<div class="stat-label">CR (일관성 비율)</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 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">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('colTeam')}</th>
|
||||
<th>${t('colModelScore')}</th>
|
||||
<th>${t('colModelJudge')}</th>
|
||||
<th>${t('colIntuition')}</th>
|
||||
<th>${t('colMatch')}</th>
|
||||
<th>팀</th>
|
||||
<th>모델 점수</th>
|
||||
<th>모델 판단</th>
|
||||
<th>직관 (1-5)</th>
|
||||
<th>일치</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
@@ -517,7 +553,7 @@ function renderStep4() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
`;
|
||||
@@ -527,11 +563,8 @@ function renderStep4() {
|
||||
// STEP 5
|
||||
// ──────────────────────────────────────────
|
||||
function renderStep5() {
|
||||
const qs = t('likertQ');
|
||||
const scale = t('likertScale');
|
||||
|
||||
const groups = qs.map((q, qi) => {
|
||||
const opts = scale.map((label, si) => {
|
||||
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 `
|
||||
@@ -553,15 +586,15 @@ function renderStep5() {
|
||||
return `
|
||||
<div class="step-wrap">
|
||||
<div class="card">
|
||||
<h2 class="section-title">${t('step5Title')}</h2>
|
||||
<p class="section-subtitle">${t('step5Desc')}</p>
|
||||
<h2 class="section-title">사후 설문</h2>
|
||||
<p class="section-subtitle">마지막으로 간단한 설문에 답해주세요.</p>
|
||||
${groups}
|
||||
<div class="form-group">
|
||||
<label>${t('openQ')}</label>
|
||||
<textarea class="open-textarea" id="open-resp" placeholder="${t('openPh')}">${escHtml(state.post_survey.open_response)}</textarea>
|
||||
<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">${t('btnSubmit')}</button>
|
||||
<button class="btn btn-primary btn-lg" id="btn-submit">제출하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -569,17 +602,22 @@ function renderStep5() {
|
||||
}
|
||||
|
||||
function bindStep5() {
|
||||
document.querySelectorAll('.likert-options input[type="radio"]').forEach(inp => {
|
||||
inp.addEventListener('change', e => {
|
||||
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 => {
|
||||
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(t('alertFillAll')); return; }
|
||||
if (!q1 || !q2 || !q3 || !q4) {
|
||||
alert(ALERT_FILL_ALL);
|
||||
return;
|
||||
}
|
||||
submitData();
|
||||
});
|
||||
}
|
||||
@@ -597,7 +635,7 @@ function buildPayload() {
|
||||
lambda_max: state.ahpResult.lambdaMax,
|
||||
CI: state.ahpResult.CI,
|
||||
CR: state.ahpResult.CR,
|
||||
cr_warning_shown: state.crWarningShown,
|
||||
cr_warning_shown: false, // CR 경고 UI 제거 — CSV 컬럼 호환용으로 유지
|
||||
},
|
||||
comparison: state.comparison,
|
||||
post_survey: state.post_survey,
|
||||
@@ -628,9 +666,9 @@ function renderComplete() {
|
||||
<div class="step-wrap">
|
||||
<div class="card">
|
||||
<div class="complete-screen">
|
||||
<div class="complete-icon">🎉</div>
|
||||
<h2 class="complete-title">${t('completeTitle')}</h2>
|
||||
<p class="complete-desc" style="white-space:pre-line">${t('completeDesc')}</p>
|
||||
<h2 class="complete-title">참여해 주셔서 감사합니다!</h2>
|
||||
<p class="complete-desc" style="white-space:pre-line">실험이 완료되었습니다.
|
||||
소중한 시간을 내어 참여해 주셔서 진심으로 감사드립니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -649,7 +687,10 @@ function goStep(n) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -662,8 +703,10 @@ function disableLeaveWarning() {
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', renderApp);
|
||||
|
||||
@@ -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 20–25 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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user