first commit

This commit is contained in:
2026-07-03 20:26:58 +09:00
commit 689d367b84
828 changed files with 269007 additions and 0 deletions
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
+20
View File
@@ -0,0 +1,20 @@
<!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/i18n.js"></script>
<script src="js/cases.js"></script>
<script src="js/ahp.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+86
View File
@@ -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 };
}
+669
View File
@@ -0,0 +1,669 @@
// ──────────────────────────────────────────
// 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,
crWarningShown: false,
crWarningPending: false,
// 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">${t('appTitle')}</span>
<button class="lang-btn" onclick="switchLang()">${t('langBtn')}</button>
</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 notices = t('step1Notices').map(n => `<p>${n}</p>`).join('');
const gradeOpts = t('step1Q1opts').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">${t('step1Title')}</h1>
<p class="section-subtitle">${t('step1Intro')}</p>
<div class="notice-box">${notices}</div>
<div class="form-group">
<label>${t('step1Q1')}</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>
</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(t('alertFillAll')); 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 varNames = t('varNames');
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>
<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 scale = t('judgmentScale');
const likertBtns = 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">
<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">${t('judgmentLabel')}</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>
</div>
</div>
<p class="text-muted text-center" style="font-size:.8rem;margin-top:.75rem">${t('step2Instruction')}</p>
</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(t('alertSelectJudgment')); 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_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];
}
function getAHPDescription(sliderPos, leftLabel, rightLabel) {
if (sliderPos === 0) return t('ahpEqual');
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);
}
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">
<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">${t('step3Title')}</h2>
<p class="section-subtitle">${t('step3Instruction')}</p>
<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>
${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>
</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);
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() {
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;
const result = runAHP(raw);
state.ahpResult = result;
if (result.CR > 0.10) {
state.crWarningPending = true;
state.crWarningShown = true;
renderMain();
} else {
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 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>
<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">${t('select')}</span>`
: `<span class="badge badge-reject">${t('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>`
: c.match === false
? `<span class="badge badge-mismatch">${t('colNo')}</span>`
: `<span class="badge badge-neutral">${t('colNeutral')}</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">${t('step4Title')}</h2>
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.75rem">${t('weightsTitle')}</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>
<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>
<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>
<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>
<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>
<div class="stat-box ${CR > 0.10 ? 'stat-warn' : 'stat-ok'}">
<div class="stat-val">${CR.toFixed(3)}</div>
<div class="stat-label">${t('statCR')}</div>
</div>
</div>
<p class="text-muted mt-2">${t('totalMismatch', totalMismatch, decidable.length)}</p>
</div>
<div class="card">
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">${t('tableTitle')}</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>
</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)">${t('btnGoSurvey')}</button>
</div>
</div>
`;
}
// ──────────────────────────────────────────
// 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 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">${t('step5Title')}</h2>
<p class="section-subtitle">${t('step5Desc')}</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>
</div>
<div class="btn-row end" style="margin-top:1.5rem">
<button class="btn btn-primary btn-lg" id="btn-submit">${t('btnSubmit')}</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(t('alertFillAll')); 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: state.crWarningShown,
},
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">
<div class="complete-icon">🎉</div>
<h2 class="complete-title">${t('completeTitle')}</h2>
<p class="complete-desc" style="white-space:pre-line">${t('completeDesc')}</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 = t('leaveWarning'); };
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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
document.addEventListener('DOMContentLoaded', renderApp);
+116
View File
@@ -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';
}
+187
View File
@@ -0,0 +1,187 @@
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();
}