feat: init premium random group selector with custom sizes and scroll prevention

This commit is contained in:
JooWangi
2026-07-22 12:27:24 +09:00
commit e910696fbe
5 changed files with 2420 additions and 0 deletions
+808
View File
@@ -0,0 +1,808 @@
/* ==========================================================================
PREMIUM RANDOM GROUP SELECTOR - APPLICATION LOGIC
========================================================================== */
document.addEventListener('DOMContentLoaded', () => {
// ------------------------------------------------------------------------
// State Variables
// ------------------------------------------------------------------------
let allMembers = [];
let remainingMembers = [];
let completedGroups = [];
let isDrawing = false;
// Member Table Array State
let memberTableData = [];
// Custom Groups State
let customGroups = [
{ id: 1, name: '그룹 1', count: 5 },
{ id: 2, name: '그룹 2', count: 4 },
{ id: 3, name: '그룹 3', count: 3 }
];
let customIdCounter = 4;
// ------------------------------------------------------------------------
// DOM Elements
// ------------------------------------------------------------------------
const settingsModal = document.getElementById('settings-modal');
const btnOpenModal = document.getElementById('btn-open-modal');
const btnOpenModalSub = document.getElementById('btn-open-modal-sub');
const btnCloseModal = document.getElementById('btn-close-modal');
const btnSaveModal = document.getElementById('btn-save-modal');
const modalTabBtns = document.querySelectorAll('.modal-tab-btn');
const tabPanes = document.querySelectorAll('.tab-pane');
const currentModeIndicator = document.getElementById('current-mode-indicator');
const btnModeTable = document.getElementById('btn-mode-table');
const btnModeText = document.getElementById('btn-mode-text');
const tableViewContainer = document.getElementById('table-view-container');
const textViewContainer = document.getElementById('text-view-container');
const memberTableBody = document.getElementById('member-table-body');
const tableCountBadge = document.getElementById('table-count-badge');
const btnAddRow = document.getElementById('btn-add-row');
const btnClearTable = document.getElementById('btn-clear-table');
const memberInput = document.getElementById('member-input');
const btnPreset49num = document.getElementById('btn-preset-49num');
const btnPreset49names = document.getElementById('btn-preset-49names');
const statTotal = document.getElementById('stat-total');
const statRemaining = document.getElementById('stat-remaining');
const statGroups = document.getElementById('stat-groups');
const modePerGroup = document.getElementById('mode-per-group');
const modeTotalGroups = document.getElementById('mode-total-groups');
const modeCustomGroups = document.getElementById('mode-custom-groups');
const uniformModeControls = document.getElementById('uniform-mode-controls');
const modeSizeLabel = document.getElementById('mode-size-label');
const groupSizeVal = document.getElementById('group-size-val');
const btnSizeMinus = document.getElementById('btn-size-minus');
const btnSizePlus = document.getElementById('btn-size-plus');
const customBuilderContainer = document.getElementById('custom-builder-container');
const customGroupsList = document.getElementById('custom-groups-list');
const btnAddCustomGroup = document.getElementById('btn-add-custom-group');
const customTotalBadge = document.getElementById('custom-total-badge');
const toggleSound = document.getElementById('toggle-sound');
const btnDrawGroup = document.getElementById('btn-draw-group');
const btnDrawAll = document.getElementById('btn-draw-all');
const drawBtnText = document.getElementById('draw-btn-text');
const drawingGroupTitle = document.getElementById('drawing-group-title');
const drawingStatusText = document.getElementById('drawing-status-text');
const drawingCardsContainer = document.getElementById('drawing-cards-container');
const groupsContainer = document.getElementById('groups-container');
const emptyResults = document.getElementById('empty-results');
const btnCopyResults = document.getElementById('btn-copy-results');
const btnResetAll = document.getElementById('btn-reset-all');
// ------------------------------------------------------------------------
// Sample Data (49 Names)
// ------------------------------------------------------------------------
const SAMPLE_49_NAMES = [
'김민준', '이서준', '박도윤', '최예준', '정시우', '강하준', '조주원', '윤지호',
'장지후', '임준서', '한건우', '오우진', '서선우', '신연우', '권유준', '황은우',
'안민재', '송현우', '류유찬', '홍지훈', '고지석', '문이준', '양정우', '손유진',
'배승현', '백승민', '허시안', '유지원', '남지원', '심현준', '노이도', '하도현',
'곽태경', '성하진', '차윤성', '주원준', '우재원', '구지한', '신유현', '임서진',
'민서안', '진율', '지성현', '엄태윤', '채하민', '원도윤', '천서우', '방지호', '공유준'
];
// ------------------------------------------------------------------------
// Member Table Logic & Synchronization
// ------------------------------------------------------------------------
// Toggle Table vs Text View Mode
btnModeTable.addEventListener('click', () => {
syncTextToTableData();
btnModeTable.classList.add('active');
btnModeText.classList.remove('active');
tableViewContainer.classList.remove('hidden');
textViewContainer.classList.add('hidden');
renderMemberTable();
});
btnModeText.addEventListener('click', () => {
syncTableDataToText();
btnModeText.classList.add('active');
btnModeTable.classList.remove('active');
textViewContainer.classList.remove('hidden');
tableViewContainer.classList.add('hidden');
});
function renderMemberTable() {
memberTableBody.innerHTML = '';
tableCountBadge.textContent = `${memberTableData.length}`;
memberTableData.forEach((name, idx) => {
const tr = document.createElement('tr');
const tdIdx = document.createElement('td');
tdIdx.className = 'table-idx';
tdIdx.textContent = idx + 1;
const tdName = document.createElement('td');
const input = document.createElement('input');
input.type = 'text';
input.className = 'table-row-input';
input.value = name;
input.addEventListener('change', (e) => {
memberTableData[idx] = e.target.value.trim();
syncTableDataToText();
});
tdName.appendChild(input);
const tdAction = document.createElement('td');
tdAction.style.textAlign = 'center';
const btnRemove = document.createElement('button');
btnRemove.type = 'button';
btnRemove.className = 'btn-remove-row';
btnRemove.textContent = '✕';
btnRemove.title = '삭제';
btnRemove.addEventListener('click', () => {
memberTableData.splice(idx, 1);
renderMemberTable();
syncTableDataToText();
});
tdAction.appendChild(btnRemove);
tr.appendChild(tdIdx);
tr.appendChild(tdName);
tr.appendChild(tdAction);
memberTableBody.appendChild(tr);
});
}
function syncTextToTableData() {
const rawText = memberInput.value;
const list = rawText
.split(/[\n,]/)
.map(name => name.trim())
.filter(name => name.length > 0);
memberTableData = [...list];
}
function syncTableDataToText() {
const cleaned = memberTableData.filter(n => n && n.trim().length > 0);
memberInput.value = cleaned.join('\n');
}
// Add Row in Table
btnAddRow.addEventListener('click', () => {
memberTableData.push(`참가자 ${memberTableData.length + 1}`);
renderMemberTable();
syncTableDataToText();
const wrapper = document.querySelector('.member-table-wrapper');
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
});
// Clear Table
btnClearTable.addEventListener('click', () => {
memberTableData = [];
memberInput.value = '';
renderMemberTable();
});
// Presets in Modal
btnPreset49num.addEventListener('click', () => {
const numbers = Array.from({ length: 49 }, (_, i) => `${i + 1}`);
memberTableData = [...numbers];
syncTableDataToText();
renderMemberTable();
});
btnPreset49names.addEventListener('click', () => {
memberTableData = [...SAMPLE_49_NAMES];
syncTableDataToText();
renderMemberTable();
});
// ------------------------------------------------------------------------
// Modal Control Logic
// ------------------------------------------------------------------------
function openModal() {
settingsModal.classList.remove('hidden');
}
// Save/Apply
btnSaveModal.addEventListener('click', () => {
if (!textViewContainer.classList.contains('hidden')) {
syncTextToTableData();
} else {
syncTableDataToText();
}
loadMembersFromTableOrInput();
updateModeUI();
closeModal();
});
function closeModal() {
settingsModal.classList.add('hidden');
}
btnOpenModal.addEventListener('click', openModal);
if (btnOpenModalSub) btnOpenModalSub.addEventListener('click', openModal);
btnCloseModal.addEventListener('click', closeModal);
settingsModal.addEventListener('click', (e) => {
if (e.target === settingsModal) {
closeModal();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !settingsModal.classList.contains('hidden')) {
closeModal();
}
});
// Modal Tabs
modalTabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const targetTab = btn.getAttribute('data-tab');
modalTabBtns.forEach(b => b.classList.remove('active'));
tabPanes.forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(targetTab).classList.add('active');
});
});
// ------------------------------------------------------------------------
// Mode Toggle & Settings
// ------------------------------------------------------------------------
modePerGroup.addEventListener('change', updateModeUI);
modeTotalGroups.addEventListener('change', updateModeUI);
modeCustomGroups.addEventListener('change', updateModeUI);
function updateModeUI() {
let modeText = '';
if (modeCustomGroups.checked) {
uniformModeControls.classList.add('hidden');
customBuilderContainer.classList.remove('hidden');
modeText = '추첨 방식: 🛠️ 그룹별 명수 개별 지정 (커스텀)';
} else {
uniformModeControls.classList.remove('hidden');
customBuilderContainer.classList.add('hidden');
if (modePerGroup.checked) {
modeSizeLabel.textContent = '한 그룹당 인원수';
modeText = `추첨 방식: 그룹 당 ${groupSizeVal.value}`;
} else {
modeSizeLabel.textContent = '생성할 총 그룹 수';
modeText = `추첨 방식: 총 ${groupSizeVal.value}개 그룹 균등`;
}
}
if (currentModeIndicator) {
currentModeIndicator.textContent = modeText;
}
}
// Stepper Buttons
btnSizeMinus.addEventListener('click', () => {
let val = parseInt(groupSizeVal.value) || 1;
if (val > 1) {
groupSizeVal.value = val - 1;
updateModeUI();
}
});
btnSizePlus.addEventListener('click', () => {
let val = parseInt(groupSizeVal.value) || 1;
if (val < 100) {
groupSizeVal.value = val + 1;
updateModeUI();
}
});
groupSizeVal.addEventListener('change', updateModeUI);
// Custom Groups Builder UI
btnAddCustomGroup.addEventListener('click', () => {
customGroups.push({
id: customIdCounter++,
name: `그룹 ${customGroups.length + 1}`,
count: 3
});
renderCustomGroupsBuilder();
});
function renderCustomGroupsBuilder() {
customGroupsList.innerHTML = '';
customGroups.forEach((cg, idx) => {
const row = document.createElement('div');
row.className = 'custom-group-row';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'custom-group-name';
nameInput.value = cg.name;
nameInput.addEventListener('change', (e) => {
cg.name = e.target.value || `그룹 ${idx + 1}`;
});
const stepper = document.createElement('div');
stepper.className = 'number-stepper sm-stepper';
stepper.innerHTML = `
<button type="button" class="btn-cg-minus">-</button>
<input type="number" value="${cg.count}" min="1" max="50" class="input-cg-val">
<button type="button" class="btn-cg-plus">+</button>
`;
const inputVal = stepper.querySelector('.input-cg-val');
stepper.querySelector('.btn-cg-minus').addEventListener('click', () => {
if (cg.count > 1) {
cg.count--;
inputVal.value = cg.count;
updateCustomTotalBadge();
}
});
stepper.querySelector('.btn-cg-plus').addEventListener('click', () => {
cg.count++;
inputVal.value = cg.count;
updateCustomTotalBadge();
});
inputVal.addEventListener('change', (e) => {
let v = parseInt(e.target.value) || 1;
cg.count = Math.max(1, v);
inputVal.value = cg.count;
updateCustomTotalBadge();
});
const btnRemove = document.createElement('button');
btnRemove.type = 'button';
btnRemove.className = 'btn-remove-custom-group';
btnRemove.textContent = '✕';
btnRemove.title = '삭제';
btnRemove.addEventListener('click', () => {
if (customGroups.length <= 1) {
alert('최소 1개 이상의 그룹 설정이 필요합니다.');
return;
}
customGroups = customGroups.filter(item => item.id !== cg.id);
renderCustomGroupsBuilder();
});
row.appendChild(nameInput);
row.appendChild(stepper);
row.appendChild(btnRemove);
customGroupsList.appendChild(row);
});
updateCustomTotalBadge();
}
function updateCustomTotalBadge() {
const totalCount = customGroups.reduce((sum, item) => sum + item.count, 0);
customTotalBadge.textContent = `총 설정 인원: ${totalCount}`;
}
// Sound Toggle
toggleSound.addEventListener('change', (e) => {
window.soundEffects.enabled = e.target.checked;
});
// Action Buttons
btnDrawGroup.addEventListener('click', () => drawNextGroup(false));
btnDrawAll.addEventListener('click', () => drawNextGroup(true));
btnResetAll.addEventListener('click', resetAllData);
btnCopyResults.addEventListener('click', copyResultsToClipboard);
// ------------------------------------------------------------------------
// Core Functions
// ------------------------------------------------------------------------
function loadMembersFromTableOrInput() {
const list = memberTableData.filter(name => name && name.trim().length > 0);
if (list.length === 0) {
const numbers = Array.from({ length: 49 }, (_, i) => `${i + 1}`);
memberTableData = [...numbers];
syncTableDataToText();
renderMemberTable();
allMembers = [...numbers];
remainingMembers = [...numbers];
} else {
allMembers = [...list];
remainingMembers = [...list];
}
completedGroups = [];
updateUIStats();
renderGroups();
}
function resetAllData() {
allMembers = [];
remainingMembers = [];
completedGroups = [];
isDrawing = false;
updateUIStats();
renderGroups();
// Reset Stage to Initial Placeholder
drawingGroupTitle.textContent = 'READY';
if (drawingStatusText) drawingStatusText.textContent = '추첨 대기 중';
drawingCardsContainer.className = 'cards-grid grid-size-lg';
drawingCardsContainer.innerHTML = `
<div class="initial-placeholder">
<span class="placeholder-icon">✨</span>
<p>상단의 <strong>[다음 1개 그룹 추첨]</strong> 버튼을 눌러 그룹 편성을 시작하세요.</p>
</div>
`;
}
function updateUIStats() {
statTotal.textContent = allMembers.length;
statRemaining.textContent = remainingMembers.length;
statGroups.textContent = completedGroups.length;
if (remainingMembers.length === 0 && allMembers.length > 0) {
drawBtnText.textContent = '모든 추첨 완료';
btnDrawGroup.disabled = true;
btnDrawAll.disabled = true;
} else {
drawBtnText.textContent = '다음 1개 그룹 추첨';
btnDrawGroup.disabled = false;
btnDrawAll.disabled = false;
}
}
function shuffleArray(array) {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function getNextGroupConfig() {
const totalRemaining = remainingMembers.length;
if (modeCustomGroups.checked) {
const nextCustomIndex = completedGroups.length;
if (nextCustomIndex < customGroups.length) {
const cg = customGroups[nextCustomIndex];
const targetCount = Math.min(totalRemaining, cg.count);
return { name: cg.name, count: targetCount };
} else {
const groupNum = completedGroups.length + 1;
const targetCount = Math.min(totalRemaining, 4);
return { name: `그룹 ${groupNum}`, count: targetCount };
}
} else {
const groupNum = completedGroups.length + 1;
const groupName = `GROUP ${groupNum}`;
const inputValue = parseInt(groupSizeVal.value) || 1;
if (modePerGroup.checked) {
const targetCount = Math.min(totalRemaining, Math.max(1, inputValue));
return { name: groupName, count: targetCount };
} else {
const desiredTotalGroups = Math.max(1, inputValue);
const remainingGroupCount = Math.max(1, desiredTotalGroups - completedGroups.length);
const memberCountPerGroup = Math.ceil(totalRemaining / remainingGroupCount);
const targetCount = Math.min(totalRemaining, memberCountPerGroup);
return { name: groupName, count: targetCount };
}
}
}
// ------------------------------------------------------------------------
// Group Draw Execution
// ------------------------------------------------------------------------
async function drawNextGroup(drawAll = false) {
if (isDrawing) return;
if (remainingMembers.length === 0) {
if (allMembers.length === 0) {
openModal();
alert('먼저 ⚙️ 설정 & 명단 모달에서 참가자 명단을 입력하세요.');
} else {
alert('모든 인원의 그룹 배치가 완료되었습니다.');
}
return;
}
isDrawing = true;
btnDrawGroup.disabled = true;
btnDrawAll.disabled = true;
const speedMode = document.querySelector('input[name="anim-speed"]:checked').value;
if (drawAll) {
while (remainingMembers.length > 0) {
const config = getNextGroupConfig();
await performSingleGroupDraw(config.name, config.count, speedMode);
}
} else {
const config = getNextGroupConfig();
await performSingleGroupDraw(config.name, config.count, speedMode);
}
isDrawing = false;
updateUIStats();
}
function performSingleGroupDraw(groupName, count, speedMode) {
return new Promise((resolve) => {
const shuffled = shuffleArray(remainingMembers);
const pickedMembers = shuffled.slice(0, count);
remainingMembers = remainingMembers.filter(m => !pickedMembers.includes(m));
// Header status text update
drawingGroupTitle.textContent = groupName;
if (drawingStatusText) drawingStatusText.textContent = '무작위 추출 중...';
// Reset cards container and assign grid scaling class
drawingCardsContainer.innerHTML = '';
drawingCardsContainer.className = 'cards-grid';
if (count <= 4) {
drawingCardsContainer.classList.add('grid-size-xl');
} else if (count <= 12) {
drawingCardsContainer.classList.add('grid-size-lg');
} else if (count <= 24) {
drawingCardsContainer.classList.add('grid-size-md');
} else {
drawingCardsContainer.classList.add('grid-size-sm');
}
if (speedMode === 'instant') {
pickedMembers.forEach((member, i) => {
createCardElement(i + 1, member, true);
});
if (drawingStatusText) drawingStatusText.textContent = '추첨 완료!';
finishGroupDraw(groupName, pickedMembers);
setTimeout(resolve, 200);
return;
}
const cardElements = pickedMembers.map((member, i) => {
return createCardElement(i + 1, member, false);
});
let spinInterval = null;
let tickCount = 0;
const spinDuration = speedMode === 'fast' ? 500 : 1200;
spinInterval = setInterval(() => {
tickCount++;
if (tickCount % 2 === 0) {
window.soundEffects.playTick();
}
cardElements.forEach(cardObj => {
if (!cardObj.revealed) {
const randIdx = Math.floor(Math.random() * allMembers.length);
cardObj.reelElement.textContent = allMembers[randIdx];
}
});
}, 50);
setTimeout(() => {
clearInterval(spinInterval);
const flipStagger = speedMode === 'fast' ? 80 : 180;
pickedMembers.forEach((member, idx) => {
setTimeout(() => {
const cardObj = cardElements[idx];
cardObj.revealed = true;
cardObj.element.classList.add('revealed');
window.soundEffects.playReveal();
if (idx === pickedMembers.length - 1) {
setTimeout(() => {
window.soundEffects.playFanfare();
triggerConfetti();
if (drawingStatusText) drawingStatusText.textContent = '추첨 완료!';
finishGroupDraw(groupName, pickedMembers);
resolve();
}, 300);
}
}, idx * flipStagger);
});
}, spinDuration);
});
}
function createCardElement(index, finalName, instantRevealed) {
const card = document.createElement('div');
card.className = `draw-card ${instantRevealed ? 'revealed' : ''}`;
const inner = document.createElement('div');
inner.className = 'card-inner';
const front = document.createElement('div');
front.className = 'card-front';
const reel = document.createElement('span');
reel.className = 'slot-reel spinning';
reel.textContent = '???';
front.appendChild(reel);
const back = document.createElement('div');
back.className = 'card-back';
const numSpan = document.createElement('span');
numSpan.className = 'member-num';
numSpan.textContent = `MEMBER ${index}`;
const nameSpan = document.createElement('span');
nameSpan.className = 'member-name';
nameSpan.textContent = finalName;
back.appendChild(numSpan);
back.appendChild(nameSpan);
inner.appendChild(front);
inner.appendChild(back);
card.appendChild(inner);
drawingCardsContainer.appendChild(card);
return {
element: card,
reelElement: reel,
revealed: instantRevealed
};
}
function finishGroupDraw(groupName, members) {
completedGroups.push({
name: groupName,
members: members
});
renderGroups();
updateUIStats();
}
function renderGroups() {
groupsContainer.innerHTML = '';
if (completedGroups.length === 0) {
groupsContainer.appendChild(emptyResults);
emptyResults.style.display = 'block';
return;
}
emptyResults.style.display = 'none';
completedGroups.forEach(group => {
const card = document.createElement('div');
card.className = 'group-card';
const header = document.createElement('div');
header.className = 'group-card-header';
header.innerHTML = `
<span class="title">${group.name}</span>
<span class="count">${group.members.length}명</span>
`;
const membersList = document.createElement('div');
membersList.className = 'group-members-list';
group.members.forEach((m, idx) => {
const badge = document.createElement('span');
badge.className = 'member-badge';
badge.innerHTML = `<span class="num">${idx + 1}</span> ${m}`;
membersList.appendChild(badge);
});
card.appendChild(header);
card.appendChild(membersList);
groupsContainer.appendChild(card);
});
}
function copyResultsToClipboard() {
if (completedGroups.length === 0) {
alert('복사할 그룹 추첨 결과가 없습니다.');
return;
}
let text = `[ 무작위 그룹 추첨 결과 ]\n\n`;
completedGroups.forEach(g => {
text += `${g.name} (${g.members.length}명)\n`;
text += `${g.members.join(', ')}\n\n`;
});
navigator.clipboard.writeText(text).then(() => {
alert('추첨 결과가 클립보드에 복사되었습니다!');
}).catch(() => {
alert('복사에 실패했습니다. 직접 선택하여 복사해주세요.');
});
}
// Confetti Animation (Canvas)
function triggerConfetti() {
const canvas = document.getElementById('confetti-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const particles = [];
const colors = ['#06b6d4', '#8b5cf6', '#10b981', '#f43f5e', '#f59e0b', '#ffffff'];
for (let i = 0; i < 70; i++) {
particles.push({
x: canvas.width / 2,
y: canvas.height / 2 + 50,
vx: (Math.random() - 0.5) * 18,
vy: (Math.random() - 0.7) * 18,
size: Math.random() * 8 + 4,
color: colors[Math.floor(Math.random() * colors.length)],
rotation: Math.random() * 360,
rSpeed: (Math.random() - 0.5) * 10,
opacity: 1
});
}
let startTime = Date.now();
function animate() {
const elapsed = Date.now() - startTime;
if (elapsed > 1600) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.4;
p.rotation += p.rSpeed;
p.opacity -= 0.015;
if (p.opacity > 0) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate((p.rotation * Math.PI) / 180);
ctx.globalAlpha = Math.max(0, p.opacity);
ctx.fillStyle = p.color;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
ctx.restore();
}
});
requestAnimationFrame(animate);
}
animate();
}
// Window Resize handle
window.addEventListener('resize', () => {
const canvas = document.getElementById('confetti-canvas');
if (canvas) {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
});
// Initialize Default State
renderCustomGroupsBuilder();
updateModeUI();
// Load default 49 numbers
const defaultNumbers = Array.from({ length: 49 }, (_, i) => `${i + 1}`);
memberTableData = [...defaultNumbers];
syncTableDataToText();
renderMemberTable();
loadMembersFromTableOrInput();
});
+105
View File
@@ -0,0 +1,105 @@
/* ==========================================================================
ELEGANT AUDIO SYNTHESIZER (WEB AUDIO API)
========================================================================== */
class SoundEffects {
constructor() {
this.ctx = null;
this.enabled = true;
}
init() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) {
this.ctx = new AudioCtx();
}
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
playTick() {
if (!this.enabled) return;
this.init();
if (!this.ctx) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
// Low subtle tick
osc.frequency.setValueAtTime(600, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1200, this.ctx.currentTime + 0.03);
gain.gain.setValueAtTime(0.04, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.03);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.035);
} catch (e) {
// Audio context edge cases
}
}
playReveal() {
if (!this.enabled) return;
this.init();
if (!this.ctx) return;
try {
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(440, now);
osc.frequency.exponentialRampToValueAtTime(880, now + 0.12);
gain.gain.setValueAtTime(0.08, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.13);
} catch (e) {}
}
playFanfare() {
if (!this.enabled) return;
this.init();
if (!this.ctx) return;
try {
const now = this.ctx.currentTime;
// Elegant Glass Chimes chord: C5, E5, G5, C6
const freqs = [523.25, 659.25, 783.99, 1046.50];
freqs.forEach((f, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(f, now + idx * 0.04);
gain.gain.setValueAtTime(0.06, now + idx * 0.04);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.04 + 0.5);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now + idx * 0.04);
osc.stop(now + idx * 0.04 + 0.55);
});
} catch (e) {}
}
}
window.soundEffects = new SoundEffects();
+261
View File
@@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Premium Random Group Selector | 무작위 인원 그룹 추첨기</title>
<!-- Google Fonts: Inter & Outfit & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="logo-area">
<div class="logo-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5"></rect>
<rect x="14" y="3" width="7" height="7" rx="1.5"></rect>
<rect x="14" y="14" width="7" height="7" rx="1.5"></rect>
<rect x="3" y="14" width="7" height="7" rx="1.5"></rect>
</svg>
</div>
<div class="logo-text">
<h1>RANDOM SELECTOR</h1>
<span class="badge">PRO GROUPING</span>
</div>
</div>
<div class="header-right">
<div class="status-summary">
<div class="stat-pill">
<span class="label">전체 인원</span>
<span class="value" id="stat-total">0</span>
</div>
<div class="stat-pill highlight-pill">
<span class="label">남은 대기</span>
<span class="value" id="stat-remaining">0</span>
</div>
<div class="stat-pill">
<span class="label">완료 그룹</span>
<span class="value" id="stat-groups">0</span>
</div>
</div>
<!-- Settings Modal Trigger Icon Button -->
<button type="button" class="btn btn-secondary btn-icon-setting" id="btn-open-modal" title="명단 및 설정 변경">
<span class="icon">⚙️</span>
<span class="btn-text">설정 & 명단</span>
</button>
</div>
</header>
<!-- Main Single Column Layout -->
<main class="main-single-layout">
<!-- Top Main Action Controls Bar -->
<section class="main-action-bar">
<div class="action-info">
<span class="mode-indicator" id="current-mode-indicator">추첨 방식: 그룹 당 4명</span>
</div>
<div class="action-buttons-group">
<button type="button" class="btn btn-outline btn-md" id="btn-open-modal-sub">
⚙️ 명단/설정 변경
</button>
<button type="button" class="btn btn-primary btn-md" id="btn-draw-group">
<span class="btn-sparkle"></span>
<span class="btn-text" id="draw-btn-text">다음 1개 그룹 추첨</span>
</button>
<button type="button" class="btn btn-accent btn-md" id="btn-draw-all">
⚡ 남은 전체 한 번에 배치
</button>
</div>
</section>
<!-- Full-Width Live Draw Stage -->
<section class="stage-section">
<div class="draw-stage" id="draw-stage">
<!-- Active Drawing / Result Display (Always Visible) -->
<div class="active-draw-display" id="active-draw-display">
<div class="drawing-header">
<span class="group-title-badge" id="drawing-group-title">READY</span>
<span class="drawing-status-text" id="drawing-status-text">추첨 대기 중</span>
</div>
<div class="cards-grid grid-size-lg" id="drawing-cards-container">
<div class="initial-placeholder">
<span class="placeholder-icon"></span>
<p>상단의 <strong>[다음 1개 그룹 추첨]</strong> 버튼을 눌러 그룹 편성을 시작하세요.</p>
</div>
</div>
</div>
</div>
<!-- Completed Groups Results Area -->
<div class="results-area">
<div class="results-header">
<h3><span class="icon">🏆</span> 생성된 그룹 목록</h3>
<div class="results-actions">
<button type="button" class="btn btn-sm btn-ghost" id="btn-copy-results">📋 결과 복사</button>
<button type="button" class="btn btn-sm btn-ghost" id="btn-reset-all">🔄 전체 리셋</button>
</div>
</div>
<div class="groups-grid" id="groups-container">
<div class="empty-results" id="empty-results">
아직 추첨된 그룹이 없습니다.
</div>
</div>
</div>
</section>
</main>
<!-- Canvas for Particle / Confetti -->
<canvas id="confetti-canvas"></canvas>
<!-- ==========================================================================
SETTINGS & MEMBER MANAGEMENT MODAL WINDOW
========================================================================== -->
<div class="modal-overlay hidden" id="settings-modal">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<div class="modal-tabs">
<button type="button" class="modal-tab-btn active" data-tab="tab-members">📊 명단 관리 (표)</button>
<button type="button" class="modal-tab-btn" data-tab="tab-settings">⚙️ 그룹 & 연출 설정</button>
</div>
<button type="button" class="modal-close-btn" id="btn-close-modal" title="닫기"></button>
</div>
<!-- Modal Body -->
<div class="modal-body">
<!-- TAB 1: Member Table Management -->
<div class="tab-pane active" id="tab-members">
<div class="panel-header-sub">
<div class="preset-group">
<span class="sub-title">빠른 49명 생성:</span>
<button type="button" class="btn btn-xs btn-outline" id="btn-preset-49num">1~49번</button>
<button type="button" class="btn btn-xs btn-outline" id="btn-preset-49names">샘플이름</button>
</div>
<div class="input-mode-toggle">
<button type="button" class="btn btn-xs btn-ghost active" id="btn-mode-table">📊 표 모드</button>
<button type="button" class="btn btn-xs btn-ghost" id="btn-mode-text">📝 텍스트 일괄입력</button>
</div>
</div>
<!-- Table View Container -->
<div class="member-view-box" id="table-view-container">
<div class="table-actions-bar">
<button type="button" class="btn btn-xs btn-secondary" id="btn-add-row"> 인원 추가</button>
<button type="button" class="btn btn-xs btn-ghost" id="btn-clear-table">🗑️ 전체 비우기</button>
<span class="table-count-badge" id="table-count-badge">총 0명</span>
</div>
<div class="member-table-wrapper">
<table class="member-table">
<thead>
<tr>
<th style="width: 55px;">#</th>
<th>이름</th>
<th style="width: 60px; text-align: center;">삭제</th>
</tr>
</thead>
<tbody id="member-table-body">
<!-- Dynamic Table Rows -->
</tbody>
</table>
</div>
</div>
<!-- Text Area View Container (Hidden by default) -->
<div class="member-view-box hidden" id="text-view-container">
<div class="input-group">
<textarea id="member-input" placeholder="이름을 줄바꿈이나 콤마로 구분하여 대량 입력하세요.&#10;예:&#10;홍길동&#10;김철수&#10;이영희..."></textarea>
</div>
</div>
</div>
<!-- TAB 2: Group & Animation Settings -->
<div class="tab-pane" id="tab-settings">
<div class="setting-group">
<label class="setting-label">추첨 방식 선택</label>
<div class="radio-toggle stacked-toggle">
<input type="radio" name="group-mode" id="mode-per-group" value="per-group" checked>
<label for="mode-per-group">그룹 당 동일 N명</label>
<input type="radio" name="group-mode" id="mode-total-groups" value="total-groups">
<label for="mode-total-groups">총 N개 그룹 균등</label>
<input type="radio" name="group-mode" id="mode-custom-groups" value="custom-groups">
<label for="mode-custom-groups">🛠️ 그룹별 명수 개별 지정 (커스텀)</label>
</div>
</div>
<!-- Uniform Mode Controls -->
<div class="setting-row inline-input" id="uniform-mode-controls">
<label for="group-size-val" id="mode-size-label">한 그룹당 인원수</label>
<div class="number-stepper">
<button type="button" id="btn-size-minus">-</button>
<input type="number" id="group-size-val" value="4" min="1" max="50">
<button type="button" id="btn-size-plus">+</button>
</div>
</div>
<!-- Custom Mode Builder Controls -->
<div class="custom-builder-container hidden" id="custom-builder-container">
<div class="custom-builder-header">
<span>개별 그룹 구성 목록</span>
<span class="total-custom-badge" id="custom-total-badge">설정: 0명</span>
</div>
<div class="custom-groups-list" id="custom-groups-list">
<!-- Dynamic Custom Group Rows -->
</div>
<button type="button" class="btn btn-xs btn-outline btn-full" id="btn-add-custom-group">
➕ 그룹 추가
</button>
</div>
<hr class="modal-divider">
<div class="setting-group">
<label class="setting-label">애니메이션 연출 속도</label>
<div class="radio-toggle speed-toggle">
<input type="radio" name="anim-speed" id="speed-fast" value="fast" checked>
<label for="speed-fast">🚀 터보 (0.6초)</label>
<input type="radio" name="anim-speed" id="speed-normal" value="normal">
<label for="speed-normal">⚡ 보통 (1.4초)</label>
<input type="radio" name="anim-speed" id="speed-instant" value="instant">
<label for="speed-instant">⚡ 즉시</label>
</div>
</div>
<div class="setting-row toggle-row">
<span>사운드 효과음</span>
<label class="switch">
<input type="checkbox" id="toggle-sound" checked>
<span class="slider"></span>
</label>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-md btn-full" id="btn-save-modal">
✨ 저장 및 적용하기
</button>
</div>
</div>
</div>
</div>
<script src="audio.js"></script>
<script src="app.js"></script>
</body>
</html>
+1199
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
김우진
임민경
최선아
이미영
국혜림
조태희
최근혜
류원준
주완기
정성호
김원기
홍아름
이경민
김윤재
한승민
김신지
김용연
성형일
박주한
황동환
최현호
조찬영
한치영
문형석
김혜인
송대일
이태훈
신혜영
정은혜
채선영
허유나
김수현
김정석
김태식
최영환
박지영
권순호
정두휘
양숙영
최혜은
윤봄이
마희연
정지윤
권혁진
염승호
윤준수
김지영