add analysis python code & reorganize folder

This commit is contained in:
2026-08-14 15:30:16 +09:00
parent 22fcafa67b
commit 5cf517cebe
20 changed files with 496 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
## AHP decision analysis survey
> [https://ahp.seung6lee.com/](https://ahp.seung6lee.com/)
스타트업 지원 심사 상황에서 응답자의 직관적 판단과 AHP 가중치 모델의 판단을 비교하는 연구(2026년도 대구국제고 3학년 사회과제연구 논문 작성 활동)를 위해 제작한 웹 설문이다.
응답자는 15개 가상 팀을 직관으로 평가한 뒤 5개 평가 기준에 대한 쌍대비교를 수행하고, 두 결과의 불일치를 확인한다.
### 구조
- 서버: Express 기반 단일 파일 [server.js](server.js)
- 클라이언트: Vanilla JS (Loaded by [index.html](public/index.html))
- [cases.js](public/js/cases.js): 사례 데이터, 변인 정의, 셔플
- [ahp.js](public/js/ahp.js): AHP 행렬 구성, 고유벡터 계산, 일관성 지표
- [app.js](public/js/app.js): 상태 관리, 단계별 렌더링, 제출
- 스타일: [src/styles/main.scss](src/styles/main.scss) -> [public/css/main.css](public/css/main.css) (`npm run build:css`)
### 데이터
15개 사례는 [cases.js](public/js/cases.js)에 하드코딩되어 있으며 각각 5개 변인(안정성, 수익 가능성, 자금 효율성, 사업 계획 완성도, 사회적 가치)을 0-100 점수로 가진다. 사례는 세 유형으로 나뉜다.
| type | 개수 | 설계 의도 |
| --- | --- | --- |
| baseline | 4 | 명확 선발, 명확 탈락, 중상, 중하 |
| spike | 5 | 한 변인만 극단값(4-6 또는 96-97), 나머지는 65-72 |
| conflict | 6 | 변인 간 방향이 충돌하거나 전 변인이 50점 경계 |
### 단계별 동작
#### 1. 인트로 페이지
![](./screenshots/01.png)
- 학년 선택
- 시작 시간 기록
- 사례 랜덤 섞기
#### 2. 상황별 판단 페이지
![](./screenshots/02.png)
- 한번에 하나의 사례씩 제시
- 5개 변인을 각각 막대로 표시
- 35 미만: low
- 35 이상, 65 미만: mid
- 65 이상: high
- 응답자는 5점 척도(무조건 탈락 ~ 무조건 선발)중 선택
- 15개 모두 마치면 3단계로 넘어감
#### 3. AHP 쌍대비교 페이지
![](./screenshots/03.png)
- $_5C_2$ 개의 조합 한번에 하나씩 제시
- 응답자는 9단계 척도(-4 ~ 4)로 중요도 선택
- 모든 조합 끝나면 `runAHP()` 실행
1. `buildMatrix()`가 $5\times5$ 쌍대비교 행렬 $A$ 를 만듦.
각 쌍의 저장값을 $v$ 라 하면
$$a_{ii}=1,\qquad a_{ij}=\begin{cases}v & (v>0)\\[2pt] \dfrac{1}{|v|} & (v<0)\end{cases},\qquad a_{ji}=\dfrac{1}{a_{ij}}$$
2. `powerMethod()`가 거듭제곱법으로 주고유벡터 $w$ 를 구함. 초기값은 균등 분포이고, 매 반복마다 $Av$ 를 합으로 정규화함.
$$v^{(0)}=\tfrac{1}{n}\mathbf{1},\qquad v^{(k+1)}=\frac{Av^{(k)}}{\sum_{i} (Av^{(k)})_i}$$
종료 조건은 $\max_i \lvert v_i^{(k+1)}-v_i^{(k)}\rvert < 10^{-6}$ 이며, 최대 1000회 반복함.
3. `calcConsistency()`가 일관성 지표를 계산함. $n=5$, $RI=1.12$ 임.
$$\lambda_{max}=\frac{1}{n}\sum_{i=1}^{n}\frac{(Aw)_i}{w_i},\qquad CI=\frac{\lambda_{max}-n}{n-1},\qquad CR=\frac{CI}{RI}$$
- `finalizeAHP()`로 사례별 비교표를 만듦
- 모델 점수: `calcModelScore()`가 5개 변인 점수 $x_i$ 와 가중치 $w_i$ 의 가중합을 구함. $\sum w_i = 1$ 이므로 점수는 0-100 범위임.
$$S=\sum_{i=1}^{5} w_i x_i$$
- 모델 판단: 50점 이상이면 선발, 미만이면 탈락
- 직관 판단: `likertToJudgment()`가 4-5를 선발, 1-2를 탈락, 3을 중립으로 분류함.
- 일치 여부: 중립은 `null`로 두어 판정에서 제외하고, 나머지는 두 판단이 같으면 true, 다르면 false
#### 4. 결과 페이지
![](./screenshots/04.png)
- AHP 가중치를 백분율 막대로 보여줌.
- 전체 불일치가 7건을 넘거나 CR이 0.1을 넘으면 해당 통계 박스에 경고 스타일이 붙음.
#### 5. 사후 설문 페이지
![](./screenshots/05.png)
- 5점 리커트 4문항 + 주관식 1문항 제시함.
#### 6. 감사합니다 페이지
![](./screenshots/06.png)
- 제출 완료 페이지
- 서버로 응답 데이터 전송함.
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = "24"
+1255
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "startup-survey",
"version": "1.0.0",
"description": "스타트업 지원 심사 판단 실험 웹사이트",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js",
"build:css": "sass src/styles/main.scss public/css/main.css --no-source-map --style=expanded",
"watch:css": "sass --watch src/styles/main.scss public/css/main.css --no-source-map --style=expanded"
},
"dependencies": {
"express": "^4.18.2",
"sass": "^1.69.5"
}
}
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>스타트업 지원 심사 판단 실험</title>
<link rel="stylesheet" href="css/main.css" />
</head>
<body>
<div id="app">
<header id="app-header"></header>
<main id="app-main"></main>
</div>
<script src="js/cases.js"></script>
<script src="js/ahp.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
// PAIRS: indices into VARS array [0=안정성, 1=수익_가능성, 2=자금_효율성, 3=사업_계획_완성도, 4=사회적_가치]
const AHP_PAIRS = [
[0,1],[0,2],[0,3],[0,4],
[1,2],[1,3],[1,4],
[2,3],[2,4],
[3,4]
];
function buildMatrix(pairwiseResults) {
const n = 5;
const matrix = Array.from({length: n}, () => Array(n).fill(1));
AHP_PAIRS.forEach(([i, j], idx) => {
const val = pairwiseResults[idx];
if (val >= 1) {
matrix[i][j] = val;
matrix[j][i] = 1 / val;
} else {
matrix[j][i] = Math.abs(val);
matrix[i][j] = 1 / Math.abs(val);
}
});
return matrix;
}
function powerMethod(matrix, maxIter = 1000, threshold = 1e-6) {
const n = matrix.length;
let v = Array(n).fill(1 / n);
for (let iter = 0; iter < maxIter; iter++) {
const Av = Array(n).fill(0);
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
Av[i] += matrix[i][j] * v[j];
const sum = Av.reduce((a, b) => a + b, 0);
const vNew = Av.map(x => x / sum);
const maxDiff = Math.max(...vNew.map((x, k) => Math.abs(x - v[k])));
v = vNew;
if (maxDiff < threshold) break;
}
return v;
}
function calcConsistency(matrix, weights) {
const n = matrix.length;
const RI = [0, 0, 0.58, 0.90, 1.12, 1.24, 1.32, 1.41, 1.45];
const Aw = Array(n).fill(0);
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
Aw[i] += matrix[i][j] * weights[j];
const lambdaMax = Aw.reduce((s, v, i) => s + v / weights[i], 0) / n;
const CI = (lambdaMax - n) / (n - 1);
const CR = CI / RI[n];
return { lambdaMax, CI, CR };
}
function calcModelScore(caseData, weights) {
return VARS.reduce((sum, varKey, i) => sum + caseData[varKey] * weights[i], 0);
}
// Slider positions -4 to +4 map to AHP odd values 1,3,5,7,9
// pos=0 → 1 (equal)
// pos<0 → left more important, AHP value = 2*|pos|+1 (1,3,5,7,9)
// pos>0 → right more important, stored as negative: -(2*pos+1)
function sliderToStored(sliderPos) {
if (sliderPos === 0) return 1;
if (sliderPos < 0) return 2 * Math.abs(sliderPos) + 1;
return -(2 * sliderPos + 1);
}
function storedToSlider(val) {
if (val === 1) return 0;
if (val > 1) return -((val - 1) / 2); // left was important
return (Math.abs(val) - 1) / 2; // right was important
}
function ahpValueToScale(absVal) {
// absVal is 2-9
const levels = { 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9' };
return absVal;
}
function runAHP(pairwiseRaw) {
const matrix = buildMatrix(pairwiseRaw);
const weights = powerMethod(matrix);
const { lambdaMax, CI, CR } = calcConsistency(matrix, weights);
return { weights, lambdaMax, CI, CR, matrix };
}
+712
View File
@@ -0,0 +1,712 @@
// ──────────────────────────────────────────
// Text (한국어 전용)
// ──────────────────────────────────────────
const APP_TITLE = '스타트업 지원 심사 판단 실험';
const GRADE_OPTIONS = ['1학년', '2학년', '3학년'];
const VAR_LABELS = {
안정성: '안정성',
수익_가능성: '수익 가능성',
자금_효율성: '자금 효율성',
사업_계획_완성도: '사업 계획 완성도',
사회적_가치: '사회적 가치',
};
const JUDGMENT_SCALE = ['무조건\n탈락', '탈락', '보통', '선발', '무조건\n선발'];
const AHP_LEVELS = { 3: '약간', 5: '꽤', 7: '매우', 9: '극히' };
const LIKERT_QUESTIONS = [
'모델의 판단 결과를 보고 놀라웠다.',
'내 직관보다 모델의 판단이 더 합리적이라고 생각한다.',
'이 선발 결정을 모델에게 맡겨도 괜찮다고 생각한다.',
'이번 판단은 온전히 내 의지로 한 것이라고 느낀다.',
];
const LIKERT_SCALE = [
'전혀\n아니다',
'아니다',
'보통',
'그렇다',
'매우\n그렇다',
];
const ALERT_FILL_ALL = '모든 항목을 입력해 주세요.';
const ALERT_SELECT_JUDGMENT = '먼저 판단을 선택해 주세요.';
const LEAVE_WARNING = '실험이 완료되지 않았습니다. 나가시겠습니까?';
// ──────────────────────────────────────────
// State
// ──────────────────────────────────────────
const state = {
step: 1,
started: false,
// Step 1
meta: { grade: '', timestamp: null },
// Step 2
shuffledCases: [],
caseIndex: 0,
currentJudgment: null, // 1-5 Likert
caseStartTime: null,
intuitive_judgments: [],
// Step 3
pairIndex: 0,
sliderValues: Array(10).fill(0),
pairwiseRaw: null,
ahpResult: null,
// 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">${APP_TITLE}</span>
<span class="header-name">대구국제고 3학년 5반 이승준</span>
</div>
`;
}
function renderMain() {
const el = document.getElementById('app-main');
if (!el) return;
switch (state.step) {
case 1:
el.innerHTML = renderStep1();
bindStep1();
break;
case 2:
el.innerHTML = renderStep2();
bindStep2();
break;
case 3:
el.innerHTML = renderStep3();
bindStep3();
break;
case 4:
el.innerHTML = renderStep4();
break;
case 5:
el.innerHTML = renderStep5();
bindStep5();
break;
case 'complete':
el.innerHTML = renderComplete();
bindComplete();
break;
}
}
// ──────────────────────────────────────────
// STEP 1
// ──────────────────────────────────────────
function renderStep1() {
const { meta } = state;
const gradeOpts = GRADE_OPTIONS.map(
(o) => `
<label class="${meta.grade === o ? 'selected' : ''}">
<input type="radio" name="grade" value="${o}" ${meta.grade === o ? 'checked' : ''} />
${o}
</label>
`,
).join('');
return `
<div class="step-wrap">
<div class="card">
<h1 class="section-title">스타트업 지원 심사 판단 실험</h1>
<p class="section-subtitle">당신은 스타트업 지원 프로그램의 심사위원입니다. 제기되는 질문에 따라서 성실히 답변해주시면 감사하겠습니다.<br>총 소요 시간은 10분 이내입니다.</p>
<div class="form-group">
<label>학년</label>
<div class="radio-group" id="rg-grade">${gradeOpts}</div>
</div>
<div class="btn-row" style="margin-top:1.5rem">
<button class="btn btn-primary btn-lg btn-full" id="btn-start">실험 시작</button>
</div>
</div>
</div>
`;
}
function bindStep1() {
document.getElementById('rg-grade').addEventListener('click', (e) => {
const lbl = e.target.closest('label');
if (!lbl) return;
const val = lbl.querySelector('input').value;
state.meta.grade = val;
document
.querySelectorAll('#rg-grade label')
.forEach((l) =>
l.classList.toggle(
'selected',
l.querySelector('input').value === val,
),
);
});
document.getElementById('btn-start').addEventListener('click', () => {
if (!state.meta.grade) {
alert(ALERT_FILL_ALL);
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 varRows = VARS.map((key) => {
const score = c[key];
const level = getScoreLevel(score);
return `
<div class="var-row">
<span class="var-name">${VAR_LABELS[key]}</span>
<div class="var-bar-track">
<div class="var-bar-fill" data-level="${level}" style="width:${score}%"></div>
</div>
<span class="var-score">${score}</span>
</div>
`;
}).join('');
const likertBtns = JUDGMENT_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">
<p class="step-instruction">당신은 스타트업 지원 프로그램의 심사위원입니다. 아래 팀의 정보를 보고 지원금 지급 가능성을 직관적으로 평가해주세요.</p>
<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">이 팀의 지원금 지급 가능성을 평가하세요</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' : ''}>다음</button>
</div>
</div>
</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(ALERT_SELECT_JUDGMENT);
return;
}
const elapsed = Date.now() - caseStart;
const c = state.shuffledCases[state.caseIndex];
state.intuitive_judgments.push({
case_id: c.id,
presented_order: state.caseIndex + 1,
judgment: state.currentJudgment, // 1-5
response_time_ms: elapsed,
});
state.caseIndex++;
state.currentJudgment = null;
if (state.caseIndex >= state.shuffledCases.length) {
goStep(3);
} else {
renderMain();
}
});
}
// ──────────────────────────────────────────
// STEP 3
// ──────────────────────────────────────────
const AHP_PAIR_LABELS = [
['안정성', '수익 가능성'],
['안정성', '자금 효율성'],
['안정성', '사업 계획 완성도'],
['안정성', '사회적 가치'],
['수익 가능성', '자금 효율성'],
['수익 가능성', '사업 계획 완성도'],
['수익 가능성', '사회적 가치'],
['자금 효율성', '사업 계획 완성도'],
['자금 효율성', '사회적 가치'],
['사업 계획 완성도', '사회적 가치'],
];
function getPairLabels(idx) {
return AHP_PAIR_LABELS[idx];
}
function getAHPDescription(sliderPos, leftLabel, rightLabel) {
if (sliderPos === 0) return '두 기준이 동등하게 중요합니다';
const ahpVal = Math.abs(sliderToStored(sliderPos)); // 3,5,7,9
const level = AHP_LEVELS[ahpVal] || String(ahpVal);
const crit = sliderPos < 0 ? leftLabel : rightLabel;
return `${crit}이(가) ${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">
<p class="step-instruction">제시되는 2개의 평가 기준 중 어느 것이 더 중요한지 비교해주세요. 왼쪽이 더 중요하면 슬라이더를 왼쪽으로, 오른쪽이 더 중요하면 오른쪽으로 움직이세요.</p>
<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">AHP 쌍대비교</h2>
<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>
<div class="btn-row end" style="margin-top:1.2rem">
<button class="btn btn-primary" id="btn-pair-next">다음</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);
}
function advancePair() {
const total = AHP_PAIRS.length;
if (state.pairIndex < total - 1) {
state.pairIndex++;
renderMain();
} else {
const raw = state.sliderValues.map((s) => sliderToStored(s));
state.pairwiseRaw = raw;
// CR은 분석용으로 계속 기록하되, 모순이 있어도 재입력 없이 그대로 진행
state.ahpResult = runAHP(raw);
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 weightRows = VARS.map((key, i) => {
const pct = Math.round(weights[i] * 100);
return `
<div class="wc-row">
<span class="wc-label">${VAR_LABELS[key]}</span>
<div class="wc-track"><div class="wc-fill" style="width:${pct}%"></div></div>
<span class="wc-pct">${pct}%</span>
</div>
`;
}).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">선발</span>`
: `<span class="badge badge-reject">탈락</span>`;
const iLabel =
c.intuitive_category === 'select'
? `<span class="badge badge-select">${c.intuitive_likert}</span>`
: c.intuitive_category === 'reject'
? `<span class="badge badge-reject">${c.intuitive_likert}</span>`
: `<span class="badge badge-neutral">${c.intuitive_likert}</span>`;
const matchBadge =
c.match === true
? `<span class="badge badge-match">✓</span>`
: c.match === false
? `<span class="badge badge-mismatch">✗</span>`
: `<span class="badge badge-neutral">△</span>`;
return `
<tr class="${mis ? 'mismatch' : neu ? 'neutral-row' : ''}">
<td>Team ${c.team}</td>
<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">결과 비교</h2>
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.75rem">나의 판단 기준 (AHP 가중치)</h3>
<div class="weight-chart">${weightRows}</div>
</div>
<div class="card">
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">핵심 통계</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">전체 불일치율</div>
</div>
<div class="stat-box">
<div class="stat-val">${baseline.filter((c) => !c.match).length}/${baseline.length}</div>
<div class="stat-label">베이스라인<br>불일치율</div>
</div>
<div class="stat-box">
<div class="stat-val">${spike.filter((c) => !c.match).length}/${spike.length}</div>
<div class="stat-label">스파이크 사례<br>불일치율</div>
</div>
<div class="stat-box">
<div class="stat-val">${conflict.filter((c) => !c.match).length}/${conflict.length}</div>
<div class="stat-label">충돌 사례<br>불일치율</div>
</div>
<div class="stat-box ${CR > 0.1 ? 'stat-warn' : 'stat-ok'}">
<div class="stat-val">${CR.toFixed(3)}</div>
<div class="stat-label">CR (일관성 비율)</div>
</div>
</div>
<p class="text-muted mt-2">총 불일치: ${totalMismatch} / ${decidable.length}건 (보통 제외)</p>
</div>
<div class="card">
<h3 style="font-size:.95rem;font-weight:700;margin-bottom:.5rem">사례별 모델 vs 직관 비교</h3>
<div class="comparison-table-wrap">
<table>
<thead>
<tr>
<th>팀</th>
<th>모델 점수</th>
<th>모델 판단</th>
<th>직관 (1-5)</th>
<th>일치</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)">사후 설문으로 이동</button>
</div>
</div>
`;
}
// ──────────────────────────────────────────
// STEP 5
// ──────────────────────────────────────────
function renderStep5() {
const groups = LIKERT_QUESTIONS.map((q, qi) => {
const opts = LIKERT_SCALE.map((label, si) => {
const val = si + 1;
const checked = state.post_survey[`q${qi + 1}`] === val;
return `
<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">사후 설문</h2>
<p class="section-subtitle">마지막으로 간단한 설문에 답해주세요.</p>
${groups}
<div class="form-group">
<label>모델 판단과 직관이 달랐을 때 어떤 생각이 들었나요? (선택)</label>
<textarea class="open-textarea" id="open-resp" placeholder="자유롭게 작성해 주세요...">${escHtml(state.post_survey.open_response)}</textarea>
</div>
<div class="btn-row end" style="margin-top:1.5rem">
<button class="btn btn-primary btn-lg" id="btn-submit">제출하기</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(ALERT_FILL_ALL);
return;
}
submitData();
});
}
// ──────────────────────────────────────────
// Submit & Complete
// ──────────────────────────────────────────
function buildPayload() {
return {
meta: { timestamp: state.meta.timestamp, grade: state.meta.grade },
intuitive_judgments: state.intuitive_judgments,
ahp: {
pairwise_raw: state.pairwiseRaw,
weights: state.ahpResult.weights,
lambda_max: state.ahpResult.lambdaMax,
CI: state.ahpResult.CI,
CR: state.ahpResult.CR,
cr_warning_shown: false, // CR 경고 UI 제거 — CSV 컬럼 호환용으로 유지
},
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">
<h2 class="complete-title">참여해 주셔서 감사합니다!</h2>
<p class="complete-desc" style="white-space:pre-line">실험이 완료되었습니다.
소중한 시간을 내어 참여해 주셔서 진심으로 감사드립니다.</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 = LEAVE_WARNING;
};
window.addEventListener('beforeunload', window._leaveHandler);
}
function disableLeaveWarning() {
if (window._leaveHandler) {
window.removeEventListener('beforeunload', window._leaveHandler);
window._leaveHandler = null;
}
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
document.addEventListener('DOMContentLoaded', renderApp);
+116
View File
@@ -0,0 +1,116 @@
const CASES = [
{
id: 1, industry: '헬스케어', industryEn: 'Healthcare',
안정성: 85, 수익_가능성: 82, 자금_효율성: 80,
사업_계획_완성도: 88, 사회적_가치: 84,
type: 'baseline', note: '명확 선발'
},
{
id: 2, industry: '물류', industryEn: 'Logistics',
안정성: 18, 수익_가능성: 22, 자금_효율성: 15,
사업_계획_완성도: 20, 사회적_가치: 25,
type: 'baseline', note: '명확 탈락'
},
{
id: 3, industry: '교육', industryEn: 'Education',
안정성: 68, 수익_가능성: 72, 자금_효율성: 65,
사업_계획_완성도: 70, 사회적_가치: 66,
type: 'baseline', note: '무난 중상'
},
{
id: 4, industry: '제조', industryEn: 'Manufacturing',
안정성: 35, 수익_가능성: 38, 자금_효율성: 42,
사업_계획_완성도: 30, 사회적_가치: 35,
type: 'baseline', note: '무난 중하'
},
{
id: 5, industry: '핀테크', industryEn: 'Fintech',
안정성: 5, 수익_가능성: 68, 자금_효율성: 72,
사업_계획_완성도: 70, 사회적_가치: 65,
type: 'spike', spike_var: '안정성', spike_dir: 'down'
},
{
id: 6, industry: '에너지', industryEn: 'Energy',
안정성: 70, 수익_가능성: 97, 자금_효율성: 68,
사업_계획_완성도: 65, 사회적_가치: 72,
type: 'spike', spike_var: '수익 가능성', spike_dir: 'up'
},
{
id: 7, industry: 'IT', industryEn: 'IT',
안정성: 65, 수익_가능성: 70, 자금_효율성: 4,
사업_계획_완성도: 68, 사회적_가치: 72,
type: 'spike', spike_var: '자금 효율성', spike_dir: 'down'
},
{
id: 8, industry: '바이오', industryEn: 'Biotech',
안정성: 68, 수익_가능성: 65, 자금_효율성: 70,
사업_계획_완성도: 96, 사회적_가치: 66,
type: 'spike', spike_var: '사업 계획 완성도', spike_dir: 'up'
},
{
id: 9, industry: '미디어', industryEn: 'Media',
안정성: 72, 수익_가능성: 68, 자금_효율성: 65,
사업_계획_완성도: 70, 사회적_가치: 6,
type: 'spike', spike_var: '사회적 가치', spike_dir: 'down'
},
{
id: 10, industry: '소셜', industryEn: 'Social',
안정성: 95, 수익_가능성: 95, 자금_효율성: 15,
사업_계획_완성도: 20, 사회적_가치: 25,
type: 'conflict', note: '수익·안정성 최고 vs 나머지 최저'
},
{
id: 11, industry: '복지', industryEn: 'Welfare',
안정성: 20, 수익_가능성: 18, 자금_효율성: 22,
사업_계획_완성도: 25, 사회적_가치: 95,
type: 'conflict', note: '사회가치만 최고 나머지 최저'
},
{
id: 12, industry: '환경', industryEn: 'Environment',
안정성: 88, 수익_가능성: 85, 자금_효율성: 90,
사업_계획_완성도: 92, 사회적_가치: 12,
type: 'conflict', note: '사회가치만 최저 나머지 모두 최고'
},
{
id: 13, industry: '문화', industryEn: 'Culture',
안정성: 15, 수익_가능성: 92, 자금_효율성: 18,
사업_계획_완성도: 20, 사회적_가치: 88,
type: 'conflict', note: '수익·사회가치 높음 나머지 낮음'
},
{
id: 14, industry: '농업', industryEn: 'Agriculture',
안정성: 90, 수익_가능성: 22, 자금_효율성: 88,
사업_계획_완성도: 85, 사회적_가치: 82,
type: 'conflict', note: '수익만 최저 나머지 모두 높음'
},
{
id: 15, industry: '패션', industryEn: 'Fashion',
안정성: 48, 수익_가능성: 52, 자금_효율성: 50,
사업_계획_완성도: 47, 사회적_가치: 53,
type: 'conflict', note: '모든 변인 50점대 경계선'
},
];
const TEAM_LETTERS = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O'];
const VARS = ['안정성', '수익_가능성', '자금_효율성', '사업_계획_완성도', '사회적_가치'];
function shuffleCases() {
const indices = CASES.map((_, i) => i);
// Fisher-Yates shuffle
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
return indices.map((origIdx, newIdx) => ({
...CASES[origIdx],
teamLetter: TEAM_LETTERS[newIdx],
presentedOrder: newIdx + 1,
}));
}
function getScoreLevel(score) {
if (score < 35) return 'low';
if (score < 65) return 'mid';
return 'high';
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

+120
View File
@@ -0,0 +1,120 @@
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 cssDir = path.join(BASE, 'public', 'css');
fs.mkdirSync(cssDir, { recursive: true });
const result = sass.compile(path.join(BASE, 'src', 'styles', 'main.scss'), { style: 'compressed' });
fs.writeFileSync(path.join(cssDir, 'main.css'), result.css);
console.log('SCSS compiled successfully');
} catch (err) {
console.error('SCSS compile error:', err.message);
}
// Ensure data directory & files exist (data/ is gitignored, so a fresh clone has neither)
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
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', 'grade'];
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.grade));
// 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}`));
+912
View File
@@ -0,0 +1,912 @@
// ──────────────────────────────────────────
// Variables — monochrome only
// ──────────────────────────────────────────
$ink: #111111; // 본문 / 강조 (검정)
$ink-soft: #333333;
$muted: #6E6E6E; // 보조 텍스트 (회색)
$line: #111111; // 구조 구분선 (검정 border)
$line-soft: #CCCCCC; // 입력/바/내부 구분선 (회색)
$track: #E5E5E5; // 바 트랙 (회색)
$wash: #F5F5F5; // 유일하게 허용하는 옅은 회색 면
$white: #FFFFFF;
$radius: 0;
// ──────────────────────────────────────────
// 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: $white;
color: $ink;
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
// 클릭 요소 focus 시 별도 디자인 없음
:focus,
:focus-visible { outline: none; }
// ──────────────────────────────────────────
// Layout
// ──────────────────────────────────────────
#app { display: flex; flex-direction: column; min-height: 100vh; }
#app-header {
background: $white;
border-bottom: 1px solid $line;
padding: 0 1.5rem;
position: sticky;
top: 0;
z-index: 100;
}
.header-inner {
max-width: 760px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1rem;
height: 56px;
}
.header-title {
font-size: .85rem;
font-weight: 700;
color: $ink;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -.01em;
}
.header-name {
direction: rtl;
font-size: .85rem;
font-weight: 700;
color: $ink;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -.01em;
}
#app-main {
flex: 1;
padding: 2rem 1rem 4rem;
display: flex;
justify-content: center;
align-items: center;
}
.step-wrap {
// #app-main이 flex 컨테이너라 폭을 명시하지 않으면 콘텐츠 크기로 줄어듦
width: 100%;
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: 1px;
background: $line-soft;
}
&.done::after { background: $ink; }
.dot-circle {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid $line-soft;
background: $white;
display: flex;
align-items: center;
justify-content: center;
font-size: .72rem;
font-weight: 700;
color: $muted;
position: relative;
z-index: 1;
}
.dot-label {
font-size: .68rem;
color: $muted;
text-align: center;
line-height: 1.2;
}
&.done .dot-circle { background: $ink; border-color: $ink; color: $white; }
&.active .dot-circle { background: $ink; border-color: $ink; color: $white; }
&.active .dot-label { color: $ink; font-weight: 700; }
}
}
// ──────────────────────────────────────────
// Cards
// ──────────────────────────────────────────
.card {
background: $white;
border: 1px solid $line;
border-radius: $radius;
padding: 2rem;
margin-bottom: 1.5rem;
&.card-sm { padding: 1.5rem; }
}
.section-title {
font-size: 1.3rem;
font-weight: 800;
color: $ink;
letter-spacing: -.02em;
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: 700;
margin-bottom: .5rem;
color: $ink;
}
input[type="text"] {
width: 100%;
padding: .6rem .9rem;
border: 1px solid $line-soft;
border-radius: $radius;
font-size: .9rem;
color: $ink;
background: $white;
&::placeholder { color: #A3A3A3; }
}
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: .5rem;
label {
display: flex;
align-items: center;
gap: .4rem;
padding: .5rem 1rem;
border: 1px solid $line-soft;
border-radius: $radius;
cursor: pointer;
font-size: .875rem;
font-weight: 400;
color: $muted;
margin: 0;
white-space: nowrap;
input[type="radio"] { display: none; }
&:hover { border-color: $line; color: $ink; }
&.selected { border-color: $ink; background: $ink; color: $white; font-weight: 700; }
}
}
// 카드 바깥에 놓이는 단계 안내문 — 본문보다 크게
.step-instruction {
font-size: 1.05rem;
line-height: 1.65;
font-weight: 500;
color: $ink;
margin-bottom: 1.5rem;
}
// ──────────────────────────────────────────
// Buttons
// ──────────────────────────────────────────
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4rem;
padding: .65rem 1.6rem;
border-radius: $radius;
border: 1px solid transparent;
font-size: .9rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
background: $white;
color: $ink;
&:disabled { opacity: .35; cursor: not-allowed; }
&.btn-primary {
background: $ink;
color: $white;
border-color: $ink;
&:not(:disabled):hover { background: $white; color: $ink; }
}
&.btn-outline {
background: $white;
color: $ink;
border-color: $ink;
&:not(:disabled):hover { background: $ink; color: $white; }
}
&.btn-gray {
background: $white;
color: $muted;
border-color: $line-soft;
&:not(:disabled):hover { border-color: $line; color: $ink; }
}
&.btn-lg { padding: .85rem 2.2rem; font-size: 1rem; }
&.btn-full { width: 100%; }
// 선정 / 미선정 — 미선택 상태는 회색 테두리, 선택 시 검정 반전
&.btn-select,
&.btn-reject {
flex: 1;
font-size: 1rem;
padding: .9rem 1rem;
background: $white;
color: $ink;
border-color: $line-soft;
&:not(:disabled):hover { border-color: $line; }
}
&.btn-selected-select,
&.btn-selected-reject {
flex: 1;
padding: .9rem 1rem;
font-size: 1rem;
background: $ink;
color: $white;
border-color: $ink;
}
}
.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: 700;
}
.progress-track {
height: 4px;
background: $track;
border-radius: $radius;
overflow: hidden;
.progress-fill {
height: 100%;
background: $ink;
transition: width .3s ease;
}
}
}
.case-card {
.case-header {
display: flex;
align-items: center;
gap: .75rem;
margin-bottom: 1.4rem;
padding-bottom: 1rem;
border-bottom: 1px solid $line-soft;
.team-badge {
font-size: 1.4rem;
font-weight: 800;
color: $ink;
background: $white;
border: 1px solid $line;
border-radius: $radius;
padding: .2rem .7rem;
line-height: 1.4;
}
.industry-tag {
font-size: .82rem;
background: $white;
border: 1px solid $line-soft;
border-radius: $radius;
padding: .25rem .7rem;
color: $muted;
font-weight: 400;
}
}
}
.var-row {
display: flex;
align-items: center;
gap: .75rem;
margin-bottom: .85rem;
.var-name {
font-size: .82rem;
font-weight: 400;
color: $ink-soft;
min-width: 110px;
flex-shrink: 0;
}
.var-bar-track {
flex: 1;
height: 8px;
background: $track;
border-radius: $radius;
overflow: hidden;
.var-bar-fill {
height: 100%;
transition: width .4s ease;
}
}
.var-score {
font-size: .85rem;
font-weight: 700;
min-width: 32px;
text-align: right;
color: $ink;
font-variant-numeric: tabular-nums;
}
}
// 점수 수준은 색이 아니라 회색 농도로 구분
.var-bar-fill[data-level="low"] { background: #C7C7C7; }
.var-bar-fill[data-level="mid"] { background: #8A8A8A; }
.var-bar-fill[data-level="high"] { background: $ink; }
.judgment-section {
margin-top: 1.5rem;
padding-top: 1.2rem;
border-top: 1px solid $line-soft;
.judgment-label {
font-size: .875rem;
font-weight: 700;
margin-bottom: .75rem;
color: $ink;
}
}
.confidence-section {
margin-top: 1.2rem;
padding: 1rem 1.2rem;
background: $white;
border: 1px solid $line-soft;
border-radius: $radius;
.confidence-label {
font-size: .82rem;
font-weight: 700;
color: $ink;
margin-bottom: .6rem;
display: flex;
justify-content: space-between;
}
.confidence-slider {
width: 100%;
appearance: none;
height: 4px;
border-radius: $radius;
background: $track;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: $ink;
cursor: pointer;
border: none;
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: $ink;
border: none;
cursor: pointer;
}
}
.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;
margin-top: .5rem;
}
.judgment-likert-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
padding: .7rem .3rem;
border: 1px solid $line-soft;
border-radius: $radius;
background: $white;
cursor: pointer;
+ .judgment-likert-btn { margin-left: -1px; }
.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: $line; position: relative; z-index: 1; }
// 값별 색상 구분 없이 선택만 검정 반전
&.active,
&[data-val].active {
border-color: $ink;
background: $ink;
position: relative;
z-index: 2;
.jl-num, .jl-label { color: $white; }
}
}
// ──────────────────────────────────────────
// AHP (Step 3)
// ──────────────────────────────────────────
.ahp-pair-card {
.pair-title {
font-size: .85rem;
color: $muted;
margin-bottom: 1.2rem;
font-weight: 400;
}
.pair-criteria {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.2rem;
.crit {
flex: 1;
text-align: center;
font-size: 1rem;
font-weight: 400;
color: $muted;
padding: .6rem;
border-radius: $radius;
&.crit-left { text-align: right; }
&.crit-right { text-align: left; }
// 우세한 쪽은 색이 아니라 굵기/명도로 강조
&.crit-active-left,
&.crit-active-right { color: $ink; font-weight: 800; }
}
.vs-badge {
font-size: .75rem;
font-weight: 700;
color: $muted;
background: $white;
border: 1px solid $line-soft;
border-radius: $radius;
padding: .2rem .5rem;
flex-shrink: 0;
}
}
}
.ahp-slider-wrap {
padding: .5rem 0;
}
.ahp-scale-labels {
display: flex;
justify-content: space-between;
margin-bottom: .5rem;
// Pad by half the thumb width (11px) so labels align with thumb at min/max
padding: 0 11px;
span {
font-size: .7rem;
color: $muted;
font-weight: 400;
text-align: center;
min-width: 0;
}
.sc-mid { font-weight: 800; color: $ink; font-size: .72rem; }
}
.ahp-slider {
width: 100%;
appearance: none;
height: 4px;
border-radius: $radius;
background: $track;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: $ink;
border: none;
cursor: pointer;
}
&::-moz-range-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
background: $ink;
border: none;
cursor: pointer;
}
}
.ahp-description {
text-align: center;
margin-top: 1rem;
padding: .7rem 1rem;
background: $white;
border: 1px solid $line-soft;
border-radius: $radius;
font-size: .875rem;
font-weight: 700;
color: $ink;
min-height: 42px;
}
// ──────────────────────────────────────────
// 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: 400;
color: $ink-soft;
min-width: 110px;
flex-shrink: 0;
}
.wc-track {
flex: 1;
height: 14px;
background: $track;
border-radius: $radius;
overflow: hidden;
.wc-fill {
height: 100%;
background: $ink;
transition: width .5s ease;
}
}
.wc-pct {
font-size: .82rem;
font-weight: 700;
color: $ink;
min-width: 38px;
text-align: right;
font-variant-numeric: tabular-nums;
}
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: .75rem;
margin-top: 1rem;
.stat-box {
background: $white;
border: 1px solid $line-soft;
border-radius: $radius;
padding: 1rem;
text-align: center;
.stat-val { font-size: 1.6rem; font-weight: 800; color: $ink; font-variant-numeric: tabular-nums; }
.stat-label { font-size: .75rem; color: $muted; margin-top: .25rem; line-height: 1.3; }
// 경고/정상 모두 색 대신 테두리 굵기로만 구분
&.stat-warn { border-color: $line; }
&.stat-ok { border-color: $line-soft; }
}
}
.comparison-table-wrap {
overflow-x: auto;
margin-top: 1rem;
border: 1px solid $line;
border-radius: $radius;
table {
width: 100%;
border-collapse: collapse;
font-size: .82rem;
th {
background: $white;
padding: .6rem .8rem;
text-align: center;
font-weight: 700;
border-bottom: 1px solid $line;
white-space: nowrap;
color: $ink;
}
td {
padding: .55rem .8rem;
text-align: center;
border-bottom: 1px solid $line-soft;
color: $ink-soft;
&:first-child { font-weight: 700; color: $ink; }
}
tr:last-child td { border-bottom: none; }
// 불일치 행은 옅은 회색 면으로만 표시
tr.mismatch td { background: $wash; color: $ink; }
tr.mismatch td:first-child { color: $ink; }
}
}
.badge {
display: inline-block;
padding: .2rem .55rem;
border-radius: $radius;
font-size: .72rem;
font-weight: 700;
line-height: 1.4;
border: 1px solid transparent;
&.badge-select { background: $ink; color: $white; border-color: $ink; }
&.badge-reject { background: $white; color: $muted; border-color: $line-soft; }
&.badge-match { background: $white; color: $ink; border-color: $line; }
&.badge-mismatch { background: $ink; color: $white; border-color: $ink; }
&.badge-neutral { background: $white; color: $muted; border-color: $line-soft; }
}
.neutral-row td { background: $wash; color: $ink-soft; }
// ──────────────────────────────────────────
// Post Survey (Step 5)
// ──────────────────────────────────────────
.likert-group {
margin-bottom: 1.5rem;
.likert-q {
font-size: .9rem;
font-weight: 700;
color: $ink;
margin-bottom: .75rem;
line-height: 1.5;
}
.likert-options {
display: flex;
label {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
cursor: pointer;
+ label .likert-btn { margin-left: -1px; }
input[type="radio"] { display: none; }
.likert-btn {
width: 100%;
padding: .55rem .2rem;
border: 1px solid $line-soft;
border-radius: $radius;
text-align: center;
font-size: .9rem;
font-weight: 700;
color: $muted;
background: $white;
}
.likert-desc { font-size: .65rem; color: $muted; text-align: center; line-height: 1.2; }
input:checked + .likert-btn { border-color: $ink; background: $ink; color: $white; position: relative; z-index: 1; }
&:hover .likert-btn { border-color: $line; }
}
}
}
.open-textarea {
width: 100%;
padding: .8rem 1rem;
border: 1px solid $line-soft;
border-radius: $radius;
font-size: .875rem;
font-family: inherit;
color: $ink;
background: $white;
resize: vertical;
min-height: 100px;
}
// ──────────────────────────────────────────
// Complete Screen
// ──────────────────────────────────────────
.complete-screen {
text-align: center;
padding: 2rem 1rem;
.complete-icon { font-size: 2.5rem; margin-bottom: 1rem; filter: grayscale(1); }
.complete-title { font-size: 1.5rem; font-weight: 800; letter-spacing: -.02em; margin-bottom: .5rem; }
.complete-desc { color: $muted; font-size: .9rem; margin-bottom: 2rem; line-height: 1.7; }
}
.json-output {
background: $white;
color: $ink-soft;
border: 1px solid $line-soft;
border-radius: $radius;
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: $line-soft;
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: $ink;
font-weight: 800;
}
// ──────────────────────────────────────────
// Responsive
// ──────────────────────────────────────────
@media (max-width: 600px) {
#app-header { padding: 0 1rem; }
.header-title { font-size: .78rem; }
.card { padding: 1.2rem; }
.step-instruction { font-size: .95rem; margin-bottom: 1.2rem; }
.step-progress .step-dot .dot-label { display: none; }
.var-row .var-name { min-width: 80px; font-size: .76rem; }
.ahp-pair-card .pair-criteria { flex-direction: column; text-align: center; gap: .5rem; }
.ahp-pair-card .pair-criteria .crit { text-align: 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; }
}
@media (max-width: 380px) {
.step-progress .step-dot .dot-circle { width: 22px; height: 22px; font-size: .65rem; }
}