initial commit
This commit is contained in:
@@ -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
|
||||
|
@@ -0,0 +1 @@
|
||||
[]
|
||||
Generated
+1255
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -0,0 +1,86 @@
|
||||
// PAIRS: indices into VARS array [0=안정성, 1=수익_가능성, 2=자금_효율성, 3=사업_계획_완성도, 4=사회적_가치]
|
||||
const AHP_PAIRS = [
|
||||
[0,1],[0,2],[0,3],[0,4],
|
||||
[1,2],[1,3],[1,4],
|
||||
[2,3],[2,4],
|
||||
[3,4]
|
||||
];
|
||||
|
||||
function buildMatrix(pairwiseResults) {
|
||||
const n = 5;
|
||||
const matrix = Array.from({length: n}, () => Array(n).fill(1));
|
||||
AHP_PAIRS.forEach(([i, j], idx) => {
|
||||
const val = pairwiseResults[idx];
|
||||
if (val >= 1) {
|
||||
matrix[i][j] = val;
|
||||
matrix[j][i] = 1 / val;
|
||||
} else {
|
||||
matrix[j][i] = Math.abs(val);
|
||||
matrix[i][j] = 1 / Math.abs(val);
|
||||
}
|
||||
});
|
||||
return matrix;
|
||||
}
|
||||
|
||||
function powerMethod(matrix, maxIter = 1000, threshold = 1e-6) {
|
||||
const n = matrix.length;
|
||||
let v = Array(n).fill(1 / n);
|
||||
for (let iter = 0; iter < maxIter; iter++) {
|
||||
const Av = Array(n).fill(0);
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = 0; j < n; j++)
|
||||
Av[i] += matrix[i][j] * v[j];
|
||||
const sum = Av.reduce((a, b) => a + b, 0);
|
||||
const vNew = Av.map(x => x / sum);
|
||||
const maxDiff = Math.max(...vNew.map((x, k) => Math.abs(x - v[k])));
|
||||
v = vNew;
|
||||
if (maxDiff < threshold) break;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function calcConsistency(matrix, weights) {
|
||||
const n = matrix.length;
|
||||
const RI = [0, 0, 0.58, 0.90, 1.12, 1.24, 1.32, 1.41, 1.45];
|
||||
const Aw = Array(n).fill(0);
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = 0; j < n; j++)
|
||||
Aw[i] += matrix[i][j] * weights[j];
|
||||
const lambdaMax = Aw.reduce((s, v, i) => s + v / weights[i], 0) / n;
|
||||
const CI = (lambdaMax - n) / (n - 1);
|
||||
const CR = CI / RI[n];
|
||||
return { lambdaMax, CI, CR };
|
||||
}
|
||||
|
||||
function calcModelScore(caseData, weights) {
|
||||
return VARS.reduce((sum, varKey, i) => sum + caseData[varKey] * weights[i], 0);
|
||||
}
|
||||
|
||||
// Slider positions -4 to +4 map to AHP odd values 1,3,5,7,9
|
||||
// pos=0 → 1 (equal)
|
||||
// pos<0 → left more important, AHP value = 2*|pos|+1 (1,3,5,7,9)
|
||||
// pos>0 → right more important, stored as negative: -(2*pos+1)
|
||||
function sliderToStored(sliderPos) {
|
||||
if (sliderPos === 0) return 1;
|
||||
if (sliderPos < 0) return 2 * Math.abs(sliderPos) + 1;
|
||||
return -(2 * sliderPos + 1);
|
||||
}
|
||||
|
||||
function storedToSlider(val) {
|
||||
if (val === 1) return 0;
|
||||
if (val > 1) return -((val - 1) / 2); // left was important
|
||||
return (Math.abs(val) - 1) / 2; // right was important
|
||||
}
|
||||
|
||||
function ahpValueToScale(absVal) {
|
||||
// absVal is 2-9
|
||||
const levels = { 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9' };
|
||||
return absVal;
|
||||
}
|
||||
|
||||
function runAHP(pairwiseRaw) {
|
||||
const matrix = buildMatrix(pairwiseRaw);
|
||||
const weights = powerMethod(matrix);
|
||||
const { lambdaMax, CI, CR } = calcConsistency(matrix, weights);
|
||||
return { weights, lambdaMax, CI, CR, matrix };
|
||||
}
|
||||
@@ -0,0 +1,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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', renderApp);
|
||||
@@ -0,0 +1,116 @@
|
||||
const CASES = [
|
||||
{
|
||||
id: 1, industry: '헬스케어', industryEn: 'Healthcare',
|
||||
안정성: 85, 수익_가능성: 82, 자금_효율성: 80,
|
||||
사업_계획_완성도: 88, 사회적_가치: 84,
|
||||
type: 'baseline', note: '명확 선발'
|
||||
},
|
||||
{
|
||||
id: 2, industry: '물류', industryEn: 'Logistics',
|
||||
안정성: 18, 수익_가능성: 22, 자금_효율성: 15,
|
||||
사업_계획_완성도: 20, 사회적_가치: 25,
|
||||
type: 'baseline', note: '명확 탈락'
|
||||
},
|
||||
{
|
||||
id: 3, industry: '교육', industryEn: 'Education',
|
||||
안정성: 68, 수익_가능성: 72, 자금_효율성: 65,
|
||||
사업_계획_완성도: 70, 사회적_가치: 66,
|
||||
type: 'baseline', note: '무난 중상'
|
||||
},
|
||||
{
|
||||
id: 4, industry: '제조', industryEn: 'Manufacturing',
|
||||
안정성: 35, 수익_가능성: 38, 자금_효율성: 42,
|
||||
사업_계획_완성도: 30, 사회적_가치: 35,
|
||||
type: 'baseline', note: '무난 중하'
|
||||
},
|
||||
{
|
||||
id: 5, industry: '핀테크', industryEn: 'Fintech',
|
||||
안정성: 5, 수익_가능성: 68, 자금_효율성: 72,
|
||||
사업_계획_완성도: 70, 사회적_가치: 65,
|
||||
type: 'spike', spike_var: '안정성', spike_dir: 'down'
|
||||
},
|
||||
{
|
||||
id: 6, industry: '에너지', industryEn: 'Energy',
|
||||
안정성: 70, 수익_가능성: 97, 자금_효율성: 68,
|
||||
사업_계획_완성도: 65, 사회적_가치: 72,
|
||||
type: 'spike', spike_var: '수익 가능성', spike_dir: 'up'
|
||||
},
|
||||
{
|
||||
id: 7, industry: 'IT', industryEn: 'IT',
|
||||
안정성: 65, 수익_가능성: 70, 자금_효율성: 4,
|
||||
사업_계획_완성도: 68, 사회적_가치: 72,
|
||||
type: 'spike', spike_var: '자금 효율성', spike_dir: 'down'
|
||||
},
|
||||
{
|
||||
id: 8, industry: '바이오', industryEn: 'Biotech',
|
||||
안정성: 68, 수익_가능성: 65, 자금_효율성: 70,
|
||||
사업_계획_완성도: 96, 사회적_가치: 66,
|
||||
type: 'spike', spike_var: '사업 계획 완성도', spike_dir: 'up'
|
||||
},
|
||||
{
|
||||
id: 9, industry: '미디어', industryEn: 'Media',
|
||||
안정성: 72, 수익_가능성: 68, 자금_효율성: 65,
|
||||
사업_계획_완성도: 70, 사회적_가치: 6,
|
||||
type: 'spike', spike_var: '사회적 가치', spike_dir: 'down'
|
||||
},
|
||||
{
|
||||
id: 10, industry: '소셜', industryEn: 'Social',
|
||||
안정성: 95, 수익_가능성: 95, 자금_효율성: 15,
|
||||
사업_계획_완성도: 20, 사회적_가치: 25,
|
||||
type: 'conflict', note: '수익·안정성 최고 vs 나머지 최저'
|
||||
},
|
||||
{
|
||||
id: 11, industry: '복지', industryEn: 'Welfare',
|
||||
안정성: 20, 수익_가능성: 18, 자금_효율성: 22,
|
||||
사업_계획_완성도: 25, 사회적_가치: 95,
|
||||
type: 'conflict', note: '사회가치만 최고 나머지 최저'
|
||||
},
|
||||
{
|
||||
id: 12, industry: '환경', industryEn: 'Environment',
|
||||
안정성: 88, 수익_가능성: 85, 자금_효율성: 90,
|
||||
사업_계획_완성도: 92, 사회적_가치: 12,
|
||||
type: 'conflict', note: '사회가치만 최저 나머지 모두 최고'
|
||||
},
|
||||
{
|
||||
id: 13, industry: '문화', industryEn: 'Culture',
|
||||
안정성: 15, 수익_가능성: 92, 자금_효율성: 18,
|
||||
사업_계획_완성도: 20, 사회적_가치: 88,
|
||||
type: 'conflict', note: '수익·사회가치 높음 나머지 낮음'
|
||||
},
|
||||
{
|
||||
id: 14, industry: '농업', industryEn: 'Agriculture',
|
||||
안정성: 90, 수익_가능성: 22, 자금_효율성: 88,
|
||||
사업_계획_완성도: 85, 사회적_가치: 82,
|
||||
type: 'conflict', note: '수익만 최저 나머지 모두 높음'
|
||||
},
|
||||
{
|
||||
id: 15, industry: '패션', industryEn: 'Fashion',
|
||||
안정성: 48, 수익_가능성: 52, 자금_효율성: 50,
|
||||
사업_계획_완성도: 47, 사회적_가치: 53,
|
||||
type: 'conflict', note: '모든 변인 50점대 경계선'
|
||||
},
|
||||
];
|
||||
|
||||
const TEAM_LETTERS = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O'];
|
||||
|
||||
const VARS = ['안정성', '수익_가능성', '자금_효율성', '사업_계획_완성도', '사회적_가치'];
|
||||
|
||||
function shuffleCases() {
|
||||
const indices = CASES.map((_, i) => i);
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
return indices.map((origIdx, newIdx) => ({
|
||||
...CASES[origIdx],
|
||||
teamLetter: TEAM_LETTERS[newIdx],
|
||||
presentedOrder: newIdx + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function getScoreLevel(score) {
|
||||
if (score < 35) return 'low';
|
||||
if (score < 65) return 'mid';
|
||||
return 'high';
|
||||
}
|
||||
@@ -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 5–6 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();
|
||||
}
|
||||
@@ -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}`));
|
||||
@@ -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; }
|
||||
}
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user