initial commit

This commit is contained in:
2026-05-26 09:25:11 -05:00
commit ebbeac2231
15 changed files with 3478 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
timestamp,country,age,gender,case_1_likert,case_2_likert,case_3_likert,case_4_likert,case_5_likert,case_6_likert,case_7_likert,case_8_likert,case_9_likert,case_10_likert,case_11_likert,case_12_likert,case_13_likert,case_14_likert,case_15_likert,case_1_order,case_2_order,case_3_order,case_4_order,case_5_order,case_6_order,case_7_order,case_8_order,case_9_order,case_10_order,case_11_order,case_12_order,case_13_order,case_14_order,case_15_order,case_1_ms,case_2_ms,case_3_ms,case_4_ms,case_5_ms,case_6_ms,case_7_ms,case_8_ms,case_9_ms,case_10_ms,case_11_ms,case_12_ms,case_13_ms,case_14_ms,case_15_ms,ahp_w_stability,ahp_w_revenue,ahp_w_capital,ahp_w_plan,ahp_w_social,ahp_lambda_max,ahp_CI,ahp_CR,ahp_cr_warning,match_case_1,match_case_2,match_case_3,match_case_4,match_case_5,match_case_6,match_case_7,match_case_8,match_case_9,match_case_10,match_case_11,match_case_12,match_case_13,match_case_14,match_case_15,post_q1,post_q2,post_q3,post_q4,open_response
1 timestamp country age gender case_1_likert case_2_likert case_3_likert case_4_likert case_5_likert case_6_likert case_7_likert case_8_likert case_9_likert case_10_likert case_11_likert case_12_likert case_13_likert case_14_likert case_15_likert case_1_order case_2_order case_3_order case_4_order case_5_order case_6_order case_7_order case_8_order case_9_order case_10_order case_11_order case_12_order case_13_order case_14_order case_15_order case_1_ms case_2_ms case_3_ms case_4_ms case_5_ms case_6_ms case_7_ms case_8_ms case_9_ms case_10_ms case_11_ms case_12_ms case_13_ms case_14_ms case_15_ms ahp_w_stability ahp_w_revenue ahp_w_capital ahp_w_plan ahp_w_social ahp_lambda_max ahp_CI ahp_CR ahp_cr_warning match_case_1 match_case_2 match_case_3 match_case_4 match_case_5 match_case_6 match_case_7 match_case_8 match_case_9 match_case_10 match_case_11 match_case_12 match_case_13 match_case_14 match_case_15 post_q1 post_q2 post_q3 post_q4 open_response
+1
View File
@@ -0,0 +1 @@
[]
+1255
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "startup-survey",
"version": "1.0.0",
"description": "스타트업 지원 심사 판단 실험 웹사이트",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"sass": "^1.69.5"
}
}
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="en">
<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 };
}
+716
View File
@@ -0,0 +1,716 @@
// ──────────────────────────────────────────
// State
// ──────────────────────────────────────────
const state = {
step: 1,
started: false,
// Step 1
meta: { country: '', age: '', gender: '', 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
// ──────────────────────────────────────────
const COUNTRIES = [
'Afghanistan','Albania','Algeria','Angola','Argentina','Armenia','Australia','Austria',
'Azerbaijan','Bangladesh','Belarus','Belgium','Bolivia','Bosnia and Herzegovina','Brazil',
'Bulgaria','Cambodia','Cameroon','Canada','Chile','China','Colombia','Congo','Costa Rica',
'Croatia','Cuba','Czech Republic','Denmark','Dominican Republic','Ecuador','Egypt',
'El Salvador','Ethiopia','Finland','France','Georgia','Germany','Ghana','Greece',
'Guatemala','Haiti','Honduras','Hungary','India','Indonesia','Iran','Iraq','Ireland',
'Israel','Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kuwait','Kyrgyzstan',
'Laos','Lebanon','Libya','Lithuania','Malaysia','Mexico','Moldova','Mongolia','Morocco',
'Mozambique','Myanmar','Nepal','Netherlands','New Zealand','Nicaragua','Nigeria','Norway',
'Pakistan','Palestine','Panama','Paraguay','Peru','Philippines','Poland','Portugal',
'Romania','Russia','Rwanda','Saudi Arabia','Senegal','Serbia','Singapore','Slovakia',
'Somalia','South Africa','South Korea','Spain','Sri Lanka','Sudan','Sweden','Switzerland',
'Syria','Taiwan','Tajikistan','Tanzania','Thailand','Tunisia','Turkey','Turkmenistan',
'Uganda','Ukraine','United Arab Emirates','United Kingdom','United States','Uruguay',
'Uzbekistan','Venezuela','Vietnam','Yemen','Zambia','Zimbabwe','Other',
];
function renderStep1() {
const { meta } = state;
const notices = t('step1Notices').map(n => `<p>${n}</p>`).join('');
const countryOpts = COUNTRIES.map(c =>
`<option value="${c}" ${meta.country === c ? 'selected' : ''}>${c}</option>`
).join('');
const genderOpts = t('step1Q3opts').map(o => `
<label class="radio-label ${meta.gender === o ? 'selected' : ''}">
<input type="radio" name="gender" value="${o}" ${meta.gender === 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>
<select id="sel-country" class="form-select">
<option value="">${t('step1Q1placeholder')}</option>
${countryOpts}
</select>
</div>
<div class="form-group">
<label>${t('step1Q2')}</label>
<input type="number" id="inp-age" class="form-input" min="10" max="99"
placeholder="${t('step1Q2placeholder')}" value="${meta.age}" />
</div>
<div class="form-group">
<label>${t('step1Q3')}</label>
<div class="radio-group" id="rg-gender">${genderOpts}</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('sel-country').addEventListener('change', e => {
state.meta.country = e.target.value;
});
document.getElementById('inp-age').addEventListener('input', e => {
state.meta.age = e.target.value;
});
document.getElementById('rg-gender').addEventListener('click', e => {
const lbl = e.target.closest('label');
if (!lbl) return;
const val = lbl.querySelector('input').value;
state.meta.gender = val;
document.querySelectorAll('#rg-gender label').forEach(l =>
l.classList.toggle('selected', l.querySelector('input').value === val));
});
document.getElementById('btn-start').addEventListener('click', () => {
if (!state.meta.country || !state.meta.age || !state.meta.gender) {
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();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
}
// ──────────────────────────────────────────
// 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();
window.scrollTo({ top: 0, behavior: 'smooth' });
} 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, country: state.meta.country, age: state.meta.age, gender: state.meta.gender },
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';
}
+195
View File
@@ -0,0 +1,195 @@
const I18N = {
ko: {
appTitle: '스타트업 지원 심사 판단 실험',
langBtn: 'EN',
stepLabels: ['안내', '직관 판단', 'AHP 비교', '결과', '사후 설문'],
// Step 1
step1Title: '스타트업 지원 심사 판단 실험',
step1Intro: '당신은 스타트업 지원 프로그램의 심사위원입니다. 이 실험은 두 가지 판단 방식 — 인간의 직관과 AI 기반 의사결정 모델(AHP) — 을 비교 분석합니다. 15개의 스타트업 사례를 보고 지원금 지급 가능성을 평가한 뒤, 본인만의 평가 기준 가중치를 설정하게 됩니다.',
step1Notices: [
'정답은 없습니다. 느낌과 직관대로 판단해 주세요.',
'총 소요 시간은 약 5~6분입니다.',
'중간에 페이지를 나가면 데이터가 저장되지 않습니다.',
],
step1Q1: '국가',
step1Q1placeholder: '국가를 선택하세요',
step1Q2: '나이',
step1Q2placeholder: '나이를 입력하세요',
step1Q3: '성별',
step1Q3opts: ['남성', '여성', '기타'],
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 56 minutes.',
'Leaving the page mid-experiment will result in lost data.',
],
step1Q1: 'Country',
step1Q1placeholder: 'Select your country',
step1Q2: 'Age',
step1Q2placeholder: 'Enter your age',
step1Q3: 'Gender',
step1Q3opts: ['Male', 'Female', 'Other'],
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.',
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 = 'en';
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();
}
+117
View File
@@ -0,0 +1,117 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const sass = require('sass');
const app = express();
const BASE = __dirname;
const DATA_FILE = path.join(BASE, 'data', 'responses.json');
const CSV_FILE = path.join(BASE, 'data', 'responses.csv');
// Compile SCSS on startup
try {
const result = sass.compile(path.join(BASE, 'src', 'styles', 'main.scss'), { style: 'compressed' });
fs.writeFileSync(path.join(BASE, 'public', 'css', 'main.css'), result.css);
console.log('SCSS compiled successfully');
} catch (err) {
console.error('SCSS compile error:', err.message);
}
// Ensure data files exist
if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, JSON.stringify([], null, 2));
if (!fs.existsSync(CSV_FILE)) fs.writeFileSync(CSV_FILE, buildCSVHeader() + '\n');
// ──────────────────────────────────────────
// CSV helpers
// ──────────────────────────────────────────
function csvEscape(val) {
const s = String(val ?? '');
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function buildCSVHeader() {
const cols = ['timestamp', 'country', 'age', 'gender'];
for (let i = 1; i <= 15; i++) cols.push(`case_${i}_likert`);
for (let i = 1; i <= 15; i++) cols.push(`case_${i}_order`);
for (let i = 1; i <= 15; i++) cols.push(`case_${i}_ms`);
cols.push('ahp_w_stability','ahp_w_revenue','ahp_w_capital','ahp_w_plan','ahp_w_social');
cols.push('ahp_lambda_max','ahp_CI','ahp_CR','ahp_cr_warning');
for (let i = 1; i <= 15; i++) cols.push(`match_case_${i}`);
cols.push('post_q1','post_q2','post_q3','post_q4','open_response');
return cols.join(',');
}
function buildCSVRow(payload) {
const fields = [];
fields.push(payload.meta.timestamp, csvEscape(payload.meta.country), csvEscape(payload.meta.age), csvEscape(payload.meta.gender));
// Case judgments ordered by case_id 1-15
const byCase = {};
(payload.intuitive_judgments || []).forEach(j => { byCase[j.case_id] = j; });
for (let i = 1; i <= 15; i++) fields.push(byCase[i]?.judgment ?? '');
for (let i = 1; i <= 15; i++) fields.push(byCase[i]?.presented_order ?? '');
for (let i = 1; i <= 15; i++) fields.push(byCase[i]?.response_time_ms ?? '');
// AHP
const ahp = payload.ahp || {};
(ahp.weights || [0,0,0,0,0]).forEach(w => fields.push(Number(w).toFixed(4)));
fields.push(Number(ahp.lambda_max).toFixed(4));
fields.push(Number(ahp.CI).toFixed(4));
fields.push(Number(ahp.CR).toFixed(4));
fields.push(ahp.cr_warning_shown ? 1 : 0);
// Match by case_id
const matchByCase = {};
(payload.comparison || []).forEach(c => { matchByCase[c.case_id] = c.match; });
for (let i = 1; i <= 15; i++) {
const m = matchByCase[i];
fields.push(m === true ? 1 : m === false ? 0 : '');
}
// Post survey
const ps = payload.post_survey || {};
fields.push(ps.q1 ?? '', ps.q2 ?? '', ps.q3 ?? '', ps.q4 ?? '');
fields.push(csvEscape(ps.open_response ?? ''));
return fields.join(',');
}
// ──────────────────────────────────────────
// Routes
// ──────────────────────────────────────────
app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(BASE, 'public')));
app.post('/api/submit', (req, res) => {
try {
// Save JSON
const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
const submission = { ...req.body, server_timestamp: Date.now() };
data.push(submission);
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
// Append CSV row
const row = buildCSVRow(submission);
fs.appendFileSync(CSV_FILE, row + '\n');
res.json({ success: true, id: data.length });
} catch (err) {
console.error('Submit error:', err);
res.status(500).json({ error: err.message });
}
});
app.get('/api/responses', (req, res) => {
try {
res.json(JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));
+956
View File
@@ -0,0 +1,956 @@
// ──────────────────────────────────────────
// Variables
// ──────────────────────────────────────────
$primary: #2563EB;
$primary-dk: #1D4ED8;
$success: #16A34A;
$danger: #DC2626;
$warning: #D97706;
$muted: #6B7280;
$border: #E5E7EB;
$bg: #F3F4F6;
$card-bg: #FFFFFF;
$text: #111827;
$text-sm: #374151;
$radius: 12px;
$radius-sm: 8px;
$shadow: 0 1px 4px rgba(0,0,0,.10), 0 4px 16px rgba(0,0,0,.06);
// ──────────────────────────────────────────
// Reset & Base
// ──────────────────────────────────────────
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR', sans-serif;
background: $bg;
color: $text;
line-height: 1.6;
min-height: 100vh;
}
// ──────────────────────────────────────────
// Layout
// ──────────────────────────────────────────
#app { display: flex; flex-direction: column; min-height: 100vh; }
#app-header {
background: $card-bg;
border-bottom: 1px solid $border;
padding: 0 1.5rem;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 1px 8px rgba(0,0,0,.06);
}
.header-inner {
max-width: 760px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1rem;
height: 56px;
}
.header-title {
font-size: .85rem;
font-weight: 600;
color: $primary;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lang-btn {
font-size: .78rem;
padding: .3rem .8rem;
border: 1px solid $border;
border-radius: 20px;
background: transparent;
cursor: pointer;
color: $muted;
transition: all .15s;
white-space: nowrap;
&:hover { border-color: $primary; color: $primary; }
}
#app-main {
flex: 1;
padding: 2rem 1rem 4rem;
}
.step-wrap {
max-width: 720px;
margin: 0 auto;
}
// ──────────────────────────────────────────
// Step Progress Indicator
// ──────────────────────────────────────────
.step-progress {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 2rem;
.step-dot {
display: flex;
flex-direction: column;
align-items: center;
gap: .25rem;
flex: 1;
position: relative;
&:not(:last-child)::after {
content: '';
position: absolute;
top: 14px;
left: calc(50% + 14px);
right: calc(-50% + 14px);
height: 2px;
background: $border;
transition: background .3s;
}
&.done::after { background: $primary; }
.dot-circle {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid $border;
background: $card-bg;
display: flex;
align-items: center;
justify-content: center;
font-size: .72rem;
font-weight: 700;
color: $muted;
transition: all .3s;
position: relative;
z-index: 1;
}
.dot-label {
font-size: .68rem;
color: $muted;
text-align: center;
line-height: 1.2;
}
&.done .dot-circle { background: $primary; border-color: $primary; color: #fff; }
&.active .dot-circle { background: $primary; border-color: $primary; color: #fff; box-shadow: 0 0 0 4px rgba($primary, .2); }
&.active .dot-label { color: $primary; font-weight: 600; }
}
}
// ──────────────────────────────────────────
// Cards
// ──────────────────────────────────────────
.card {
background: $card-bg;
border-radius: $radius;
box-shadow: $shadow;
padding: 2rem;
margin-bottom: 1.5rem;
&.card-sm { padding: 1.5rem; }
}
.section-title {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: .4rem;
}
.section-subtitle {
color: $muted;
font-size: .9rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
// ──────────────────────────────────────────
// Forms (Step 1)
// ──────────────────────────────────────────
.form-group {
margin-bottom: 1.2rem;
label {
display: block;
font-size: .875rem;
font-weight: 600;
margin-bottom: .5rem;
color: $text-sm;
}
input[type="text"],
input[type="number"] {
width: 100%;
padding: .6rem .9rem;
border: 1.5px solid $border;
border-radius: $radius-sm;
font-size: .9rem;
outline: none;
transition: border-color .2s;
font-family: inherit;
&:focus { border-color: $primary; }
&::placeholder { color: #9CA3AF; }
}
}
.form-select {
width: 100%;
padding: .6rem .9rem;
border: 1.5px solid $border;
border-radius: $radius-sm;
font-size: .9rem;
font-family: inherit;
outline: none;
background: #fff;
color: $text;
cursor: pointer;
transition: border-color .2s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236B7280' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right .9rem center;
padding-right: 2.5rem;
&:focus { border-color: $primary; }
}
.radio-label {
display: flex;
align-items: center;
gap: .4rem;
padding: .5rem 1rem;
border: 1.5px solid $border;
border-radius: 24px;
cursor: pointer;
font-size: .875rem;
color: $text-sm;
transition: all .15s;
margin: 0;
white-space: nowrap;
input[type="radio"] { display: none; }
&:hover { border-color: $primary; color: $primary; }
&.selected { border-color: $primary; background: rgba($primary,.07); color: $primary; font-weight: 600; }
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: .6rem;
label {
display: flex;
align-items: center;
gap: .4rem;
padding: .5rem 1rem;
border: 1.5px solid $border;
border-radius: 24px;
cursor: pointer;
font-size: .875rem;
font-weight: normal;
color: $text-sm;
transition: all .15s;
margin: 0;
white-space: nowrap;
input[type="radio"] { display: none; }
&:hover { border-color: $primary; color: $primary; }
&.selected { border-color: $primary; background: rgba($primary,.07); color: $primary; font-weight: 600; }
}
}
.notice-box {
background: #EFF6FF;
border: 1px solid #BFDBFE;
border-radius: $radius-sm;
padding: 1rem 1.2rem;
margin-bottom: 1.5rem;
p {
font-size: .85rem;
color: #1E40AF;
line-height: 1.6;
&::before { content: ''; }
+ p { margin-top: .3rem; }
}
}
// ──────────────────────────────────────────
// Buttons
// ──────────────────────────────────────────
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4rem;
padding: .65rem 1.6rem;
border-radius: $radius-sm;
border: none;
font-size: .9rem;
font-weight: 600;
cursor: pointer;
transition: all .15s;
text-decoration: none;
&:disabled { opacity: .45; cursor: not-allowed; }
&.btn-primary {
background: $primary;
color: #fff;
&:not(:disabled):hover { background: $primary-dk; transform: translateY(-1px); box-shadow: 0 4px 12px rgba($primary,.35); }
}
&.btn-outline {
background: transparent;
color: $primary;
border: 1.5px solid $primary;
&:hover { background: rgba($primary,.06); }
}
&.btn-gray {
background: #F3F4F6;
color: $text-sm;
border: 1.5px solid $border;
&:not(:disabled):hover { background: $border; }
}
&.btn-lg { padding: .85rem 2.2rem; font-size: 1rem; border-radius: 10px; }
&.btn-full { width: 100%; }
&.btn-select {
background: $primary;
color: #fff;
flex: 1;
font-size: 1rem;
padding: .9rem 1rem;
&:not(:disabled):hover { background: $primary-dk; }
}
&.btn-reject {
background: #F9FAFB;
color: #6B7280;
border: 1.5px solid $border;
flex: 1;
font-size: 1rem;
padding: .9rem 1rem;
&:not(:disabled):hover { background: #F3F4F6; color: #374151; }
}
&.btn-selected-select { background: $success; color: #fff; flex: 1; padding: .9rem 1rem; font-size: 1rem; }
&.btn-selected-reject { background: $danger; color: #fff; flex: 1; padding: .9rem 1rem; font-size: 1rem; }
}
.btn-row {
display: flex;
gap: .75rem;
margin-top: 1.2rem;
&.center { justify-content: center; }
&.end { justify-content: flex-end; }
}
// ──────────────────────────────────────────
// Case Card (Step 2)
// ──────────────────────────────────────────
.progress-bar-wrap {
margin-bottom: 1.5rem;
.progress-label {
display: flex;
justify-content: space-between;
font-size: .82rem;
color: $muted;
margin-bottom: .4rem;
font-weight: 600;
}
.progress-track {
height: 6px;
background: $border;
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: $primary;
border-radius: 3px;
transition: width .4s ease;
}
}
}
.case-card {
.case-header {
display: flex;
align-items: center;
gap: .75rem;
margin-bottom: 1.4rem;
padding-bottom: 1rem;
border-bottom: 1px solid $border;
.team-badge {
font-size: 1.4rem;
font-weight: 800;
color: $primary;
background: rgba($primary,.08);
border-radius: $radius-sm;
padding: .3rem .8rem;
}
.industry-tag {
font-size: .82rem;
background: #F3F4F6;
border: 1px solid $border;
border-radius: 20px;
padding: .25rem .75rem;
color: $muted;
font-weight: 500;
}
}
}
.var-row {
display: flex;
align-items: center;
gap: .75rem;
margin-bottom: .85rem;
.var-name {
font-size: .82rem;
font-weight: 600;
color: $text-sm;
width: 140px;
flex-shrink: 0;
flex-grow: 0;
}
.var-bar-track {
flex: 1;
height: 10px;
background: #F3F4F6;
border-radius: 5px;
overflow: hidden;
.var-bar-fill {
height: 100%;
border-radius: 5px;
transition: width .5s ease;
}
}
.var-score {
font-size: .85rem;
font-weight: 700;
min-width: 32px;
text-align: right;
color: $text;
}
}
// Score color via data-score attribute (set inline via JS)
.var-bar-fill[data-level="low"] { background: linear-gradient(90deg, #EF4444, #F87171); }
.var-bar-fill[data-level="mid"] { background: linear-gradient(90deg, #FBBF24, #FCD34D); }
.var-bar-fill[data-level="high"] { background: linear-gradient(90deg, #22C55E, #4ADE80); }
.judgment-section {
margin-top: 1.5rem;
padding-top: 1.2rem;
border-top: 1px solid $border;
.judgment-label {
font-size: .875rem;
font-weight: 600;
margin-bottom: .75rem;
color: $text-sm;
}
}
.confidence-section {
margin-top: 1.2rem;
padding: 1rem 1.2rem;
background: #F9FAFB;
border-radius: $radius-sm;
border: 1px solid $border;
.confidence-label {
font-size: .82rem;
font-weight: 600;
color: $text-sm;
margin-bottom: .6rem;
display: flex;
justify-content: space-between;
}
.confidence-slider {
width: 100%;
appearance: none;
height: 6px;
border-radius: 3px;
background: $border;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: $primary;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,.2);
}
}
.confidence-labels {
display: flex;
justify-content: space-between;
margin-top: .4rem;
.cl { font-size: .7rem; color: $muted; }
}
}
// ──────────────────────────────────────────
// Judgment Likert (Step 2)
// ──────────────────────────────────────────
.judgment-likert-row {
display: flex;
gap: .5rem;
margin-top: .5rem;
}
.judgment-likert-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
padding: .7rem .3rem;
border: 2px solid $border;
border-radius: $radius-sm;
background: $card-bg;
cursor: pointer;
transition: all .15s;
.jl-num {
font-size: 1.1rem;
font-weight: 800;
color: $muted;
}
.jl-label {
font-size: .65rem;
color: $muted;
text-align: center;
line-height: 1.3;
}
&:hover { border-color: $primary; }
&.active {
border-color: $primary;
background: rgba($primary, .07);
.jl-num { color: $primary; }
.jl-label { color: $primary; }
}
// Color coding by value
&[data-val="1"].active { border-color: $danger; background: rgba($danger,.07);
.jl-num, .jl-label { color: $danger; } }
&[data-val="2"].active { border-color: #F97316; background: rgba(#F97316,.07);
.jl-num, .jl-label { color: #F97316; } }
&[data-val="3"].active { border-color: $warning; background: rgba($warning,.07);
.jl-num, .jl-label { color: $warning; } }
&[data-val="4"].active { border-color: #22C55E; background: rgba(#22C55E,.07);
.jl-num, .jl-label { color: #22C55E; } }
&[data-val="5"].active { border-color: $success; background: rgba($success,.07);
.jl-num, .jl-label { color: $success; } }
}
// ──────────────────────────────────────────
// AHP (Step 3)
// ──────────────────────────────────────────
.ahp-pair-card {
.pair-title {
font-size: .85rem;
color: $muted;
margin-bottom: 1.2rem;
font-weight: 500;
}
.pair-criteria {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.2rem;
min-height: 4rem;
.crit {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
text-align: right;
font-size: 1rem;
font-weight: 700;
color: $text;
padding: .6rem;
border-radius: $radius-sm;
transition: background .2s;
word-break: break-word;
&.crit-left { justify-content: flex-end; text-align: right; }
&.crit-right { justify-content: flex-start; text-align: left; }
&.crit-active-left { color: $primary; background: rgba($primary,.07); }
&.crit-active-right { color: $success; background: rgba($success,.07); }
}
.vs-badge {
font-size: .8rem;
font-weight: 700;
color: $muted;
background: $bg;
border: 1px solid $border;
border-radius: 20px;
padding: .25rem .6rem;
flex-shrink: 0;
align-self: center;
}
}
}
.ahp-slider-wrap {
padding: .5rem 0;
}
.ahp-scale-labels {
display: flex;
justify-content: space-between;
margin-bottom: .5rem;
// Pad by half the thumb width (12px) so labels align with thumb at min/max
padding: 0 12px;
span {
font-size: .7rem;
color: $muted;
font-weight: 600;
text-align: center;
min-width: 0;
}
.sc-mid { font-weight: 800; color: $text-sm; font-size: .75rem; }
}
.ahp-slider {
width: 100%;
appearance: none;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, rgba($primary,.15) 0%, $primary 50%, rgba($success,.15) 100%);
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: #fff;
border: 3px solid $primary;
box-shadow: 0 2px 6px rgba(0,0,0,.2);
cursor: pointer;
transition: border-color .2s;
}
}
.ahp-description {
text-align: center;
margin-top: 1rem;
padding: .7rem 1rem;
background: #F0F9FF;
border: 1px solid #BAE6FD;
border-radius: $radius-sm;
font-size: .875rem;
font-weight: 600;
color: #0369A1;
min-height: 42px;
}
.cr-warning {
background: #FEF9C3;
border: 1px solid #FDE047;
border-radius: $radius-sm;
padding: 1rem 1.2rem;
margin-top: 1rem;
p { font-size: .875rem; color: #713F12; margin-bottom: .75rem; font-weight: 600; }
}
// ──────────────────────────────────────────
// Results (Step 4)
// ──────────────────────────────────────────
.weight-chart {
margin-top: .5rem;
.wc-row {
display: flex;
align-items: center;
gap: .75rem;
margin-bottom: .7rem;
.wc-label {
font-size: .82rem;
font-weight: 600;
color: $text-sm;
min-width: 110px;
flex-shrink: 0;
}
.wc-track {
flex: 1;
height: 18px;
background: #F3F4F6;
border-radius: 9px;
overflow: hidden;
.wc-fill {
height: 100%;
background: linear-gradient(90deg, $primary, #60A5FA);
border-radius: 9px;
transition: width .6s ease;
}
}
.wc-pct { font-size: .82rem; font-weight: 700; color: $primary; min-width: 38px; text-align: right; }
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
margin-top: 1rem;
.stat-box {
background: $bg;
border: 1px solid $border;
border-radius: $radius-sm;
padding: 1rem;
text-align: center;
.stat-val { font-size: 1.6rem; font-weight: 800; color: $primary; }
.stat-label { font-size: .75rem; color: $muted; margin-top: .25rem; line-height: 1.3; }
&.stat-warn .stat-val { color: $warning; }
&.stat-ok .stat-val { color: $success; }
}
}
.comparison-table-wrap {
overflow-x: auto;
margin-top: 1rem;
border-radius: $radius-sm;
border: 1px solid $border;
table {
width: 100%;
border-collapse: collapse;
font-size: .82rem;
th {
background: #F9FAFB;
padding: .6rem .8rem;
text-align: center;
font-weight: 700;
border-bottom: 1px solid $border;
white-space: nowrap;
color: $text-sm;
}
td {
padding: .55rem .8rem;
text-align: center;
border-bottom: 1px solid $border;
&:first-child { font-weight: 700; }
}
tr:last-child td { border-bottom: none; }
tr.mismatch td { background: #FEF2F2; color: $danger; }
tr.mismatch td:first-child { color: $danger; }
}
}
.badge {
display: inline-block;
padding: .2rem .55rem;
border-radius: 12px;
font-size: .75rem;
font-weight: 700;
line-height: 1;
&.badge-select { background: #DBEAFE; color: $primary; }
&.badge-reject { background: #F3F4F6; color: $muted; }
&.badge-match { background: #DCFCE7; color: $success; }
&.badge-mismatch { background: #FEE2E2; color: $danger; }
&.badge-neutral { background: #FEF9C3; color: $warning; }
}
.neutral-row td { background: #FAFAF0; color: $warning; }
// ──────────────────────────────────────────
// Post Survey (Step 5)
// ──────────────────────────────────────────
.likert-group {
margin-bottom: 1.5rem;
.likert-q {
font-size: .9rem;
font-weight: 600;
color: $text;
margin-bottom: .75rem;
line-height: 1.5;
}
.likert-options {
display: flex;
gap: .5rem;
label {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
cursor: pointer;
input[type="radio"] { display: none; }
.likert-btn {
width: 100%;
padding: .55rem .2rem;
border: 2px solid $border;
border-radius: $radius-sm;
text-align: center;
font-size: .9rem;
font-weight: 700;
color: $muted;
transition: all .15s;
background: $card-bg;
}
.likert-desc { font-size: .65rem; color: $muted; text-align: center; line-height: 1.2; }
input:checked + .likert-btn { border-color: $primary; background: rgba($primary,.08); color: $primary; }
&:hover .likert-btn { border-color: $primary; }
}
}
}
.open-textarea {
width: 100%;
padding: .8rem 1rem;
border: 1.5px solid $border;
border-radius: $radius-sm;
font-size: .875rem;
font-family: inherit;
resize: vertical;
min-height: 100px;
outline: none;
transition: border-color .2s;
&:focus { border-color: $primary; }
}
// ──────────────────────────────────────────
// Complete Screen
// ──────────────────────────────────────────
.complete-screen {
text-align: center;
padding: 2rem 1rem;
.complete-icon { font-size: 3.5rem; margin-bottom: 1rem; }
.complete-title { font-size: 1.5rem; font-weight: 800; margin-bottom: .5rem; }
.complete-desc { color: $muted; font-size: .9rem; margin-bottom: 2rem; line-height: 1.7; }
}
.json-output {
background: #1E293B;
color: #94A3B8;
border-radius: $radius-sm;
padding: 1.2rem;
font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
font-size: .75rem;
line-height: 1.6;
text-align: left;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
white-space: pre;
margin-top: 1rem;
}
// ──────────────────────────────────────────
// Utilities
// ──────────────────────────────────────────
.divider {
height: 1px;
background: $border;
margin: 1.5rem 0;
}
.text-muted { color: $muted; font-size: .85rem; }
.text-center { text-align: center; }
.mt-1 { margin-top: .5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mb-1 { margin-bottom: .5rem; }
.highlight-text {
color: $primary;
font-weight: 700;
}
// ──────────────────────────────────────────
// Responsive
// ──────────────────────────────────────────
@media (max-width: 600px) {
#app-header { padding: 0 1rem; }
.header-title { font-size: .78rem; }
.card { padding: 1.2rem; }
.step-progress .step-dot .dot-label { display: none; }
.var-row .var-name { width: 100px; font-size: .76rem; }
.ahp-pair-card .pair-criteria { flex-direction: column; gap: .5rem; min-height: 0; }
.ahp-pair-card .pair-criteria .crit { text-align: center !important; justify-content: center !important; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.comparison-table-wrap table { font-size: .75rem; }
.comparison-table-wrap table td,
.comparison-table-wrap table th { padding: .4rem .5rem; }
.likert-group .likert-options label .likert-desc { display: none; }
.btn.btn-lg { padding: .75rem 1.5rem; font-size: .9rem; }
.btn-row.judgment-btns { gap: .5rem; }
}
@media (max-width: 380px) {
.step-progress .step-dot .dot-circle { width: 22px; height: 22px; font-size: .65rem; }
}
Binary file not shown.