initial commit
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user