Files
basket_utils/src/main.js
T

786 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { accountLayout, editorLayout, decorateSequenceButton } from './ui.js';
import './style.css';
import { createEditorHistory, editAction } from './editor.js';
import { addAction, addScreenAction, addShootAction, clampLocation, finalBallOwner, hasShoot, normalizeLookAt, removeAction, scheduledTrackDuration, sequenceDuration, setSequenceBallOwner, snapLocation } from './domain.js';
import { createPlaybackController, samplePlay, selectRenderSample, totalDuration } from './playback.js';
import { createBoard } from './scene.js';
import { createLocalPlayRepository } from './playRepository.js';
import { createPlayOperationCoordinator } from './playOperations.js';
import { addPlaySequence, commitActionEdit, createAppState, createAppStateFromPlay, deletePlaySequence, serializePlay, setSelectedPlayerStart } from './state.js';
import { createVideoExportJob, downloadVideoFile, VideoExportCancelledError, VideoExportStaleError, shareVideoFile, validateVideoFile } from './videoExport.js';
import { ApiError, approveUser, approveTeamJoin, changeTeamMemberRole, createServerPlayRepository, createTeam, getCurrentUser, listJoinRequests, listPendingUsers, listTeamJoinRequests, listTeamMembers, listTeams, loginAccount, logoutAccount, registerAccount, searchTeams, requestTeamJoin } from './api.js';
import { renewSessionActivity } from './api.js';
import { createSessionActivityTracker } from './sessionActivity.js';
const app = document.querySelector('#app');
app.innerHTML = accountLayout() + editorLayout();
document.querySelector('.shell').hidden = true;
const board = createBoard(document.querySelector('#board'));
let storage = null; try { storage = window.localStorage; } catch { storage = null; }
const sessionActivity = createSessionActivityTracker({ onActivity: renewSessionActivity, isAuthenticated: () => Boolean(currentUser) });
const localPlayRepository = createLocalPlayRepository(storage);
let playRepository = localPlayRepository;
let currentUser = null;
let currentTeam = null;
let accountGeneration = 0;
let searchGeneration = 0;
let ownerGeneration = 0;
let activeOwnerTeamId = null;
let teamHubView = 'joined';
let teamHubViewUserSelected = false;
let selectedTeam = null;
let teamActionView = 'library';
const operationCoordinator = createPlayOperationCoordinator();
let state = createAppState();
const editorHistory = createEditorHistory();
let panel = 'court'; let gazePick = null; let suppressClick = false; let drag = null; let mobileFocusActive = false; let focusPlaybackOpen = false;
let focusSession = 0; let focusOrientationToken = 0; let focusOrientationStatus = 'idle'; let fullscreenFocusRequest = 0; let focusEntryRequested = false;
let viewportSyncFrame = 0;
function syncAppViewportHeight() {
if (viewportSyncFrame) return;
viewportSyncFrame = requestAnimationFrame(() => {
viewportSyncFrame = 0;
const viewportHeight = Math.round(window.visualViewport?.height || window.innerHeight || 0);
if (!viewportHeight) return;
document.querySelector('.shell')?.style.setProperty('--app-viewport-height', `${viewportHeight}px`);
if (mobileFocusActive) board.resize();
});
}
function cancelActiveDrag() { const active = drag; drag = null; if (active && boardElement?.hasPointerCapture?.(active.pointerId)) boardElement.releasePointerCapture(active.pointerId); document.querySelector('.board-wrap')?.classList.remove('dragging'); if (state.boardPreview) state = { ...state, boardPreview: undefined }; }
document.querySelector('#play-name').value = state.play.name;
document.querySelector('#defense-type').value = state.play.defenseType;
document.querySelector('#save-status').textContent = '팀을 선택하세요';
let controller = createPlaybackController(state.play);
let playbackSession = false;
let resetPreview = false;
let lastFrame = performance.now();
let savedListGeneration = 0;
let passiveListGeneration = 0;
let loadGeneration = 0;
let detailTacticsGeneration = 0;
const VIDEO_WIDTH = 1280;
const VIDEO_HEIGHT = 720;
const VIDEO_FPS = 30;
let videoExportActive = false;
let videoJob = null;
let preparedVideo = null;
let videoPreviewUrl = null;
function videoSettings() {
const view = document.querySelector('#video-view')?.value || 'tactical';
return { view, selectedPlayerId: view === 'pov' ? state.selectedPlayerId : null };
}
function videoKey() {
const name = String(document.querySelector('#play-name')?.value || '').trim() || '새 전술';
return JSON.stringify({ play: state.play, name, ...videoSettings() });
}
function setVideoStatus(message) { const element = document.querySelector('#video-status'); if (element) element.textContent = message; }
function renderVideoControls() {
const exportButton = document.querySelector('#export-video'); const cancelButton = document.querySelector('#cancel-video'); const progress = document.querySelector('#video-progress'); const shareButton = document.querySelector('#share-video'); const downloadButton = document.querySelector('#download-video');
if (!exportButton) return;
const povOption = document.querySelector('#video-view option[value="pov"]'); const povPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (povOption) povOption.textContent = `선수 시점 · ${povPlayer ? `${povPlayer.team === 'offense' ? 'O' : 'D'}${povPlayer.number}` : '선택 선수'}`;
exportButton.disabled = videoExportActive; cancelButton.hidden = !videoExportActive; progress.hidden = !videoExportActive; shareButton.disabled = !preparedVideo || videoExportActive || preparedVideo.key !== videoKey(); downloadButton.disabled = !preparedVideo || videoExportActive || preparedVideo.key !== videoKey();
}
function clearPreparedVideo(message = '영상 준비 전') {
preparedVideo = null;
if (videoPreviewUrl) { URL.revokeObjectURL(videoPreviewUrl); videoPreviewUrl = null; }
const preview = document.querySelector('#video-preview'); if (preview) { preview.pause?.(); preview.removeAttribute('src'); preview.load?.(); preview.hidden = true; }
const progress = document.querySelector('#video-progress'); if (progress) progress.value = 0;
setVideoStatus(message); renderVideoControls();
}
function ensureVideoFresh() {
if (preparedVideo && preparedVideo.key !== videoKey()) clearPreparedVideo('전술이 변경되어 MP4를 다시 만들어야 합니다');
if (videoJob && videoJob.key !== videoKey()) videoJob.cancel();
}
function videoFileName(name) {
const safe = String(name || 'basket-utils-play').trim().replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-').slice(0, 70) || 'basket-utils-play';
return `${safe}.mp4`;
}
async function startVideoExport() {
if (videoJob) return;
ensureVideoFresh();
const play = structuredClone(state.play); play.name = String(document.querySelector('#play-name').value || '').trim() || '새 전술';
const settings = videoSettings(); const key = videoKey(); const duration = totalDuration(play);
if (duration <= 0) { setVideoStatus('먼저 재생할 이동 행동을 추가하세요'); return; }
clearPreparedVideo('MP4 생성 준비 중…');
const exportState = { ...state, play, view: settings.view, selectedPlayerId: settings.selectedPlayerId || state.selectedPlayerId, selectedAction: -1, playing: true, mode: 'move' };
let surface = null;
// Freeze the editor animation loop while the dedicated export surface is
// sampled. This keeps the user's current play/pause state and scrubber
// position intact when the export finishes.
videoExportActive = true; renderVideoControls(); setVideoStatus('MP4 생성 중… 0%');
try {
surface = board.createExportSurface(VIDEO_WIDTH, VIDEO_HEIGHT);
const job = createVideoExportJob({
canvas: surface.canvas,
duration,
fps: VIDEO_FPS,
fileName: videoFileName(play.name),
isCurrent: () => videoKey() === key,
renderFrame: (elapsed) => {
if (videoKey() !== key) throw new VideoExportStaleError();
surface.render(play, exportState, { ...samplePlay(play, elapsed), playing: true }, 1 / VIDEO_FPS);
},
onProgress: (progress) => { const element = document.querySelector('#video-progress'); if (element) element.value = progress; setVideoStatus(`MP4 생성 중… ${Math.round(progress * 100)}%`); },
});
job.key = key; videoJob = job;
const result = await job.promise;
if (videoKey() !== key) throw new VideoExportStaleError();
setVideoStatus('MP4 재생 정보 확인 중…');
if (job.signal.aborted) throw new VideoExportCancelledError();
const metadata = await validateVideoFile(result.file, { signal: job.signal });
if (job.signal.aborted) throw new VideoExportCancelledError();
if (videoKey() !== key) throw new VideoExportStaleError();
preparedVideo = { file: result.file, key, metadata };
videoPreviewUrl = URL.createObjectURL(result.file);
const preview = document.querySelector('#video-preview'); preview.src = videoPreviewUrl; preview.hidden = false;
setVideoStatus(`MP4 준비 완료 · ${(result.file.size / 1024 / 1024).toFixed(1)}MB · ${metadata.duration ? metadata.duration.toFixed(1) + '초' : duration.toFixed(1) + '초'}`);
} catch (error) {
if (error instanceof VideoExportCancelledError || error?.code === 'cancelled') setVideoStatus('영상 생성을 취소했습니다');
else if (error instanceof VideoExportStaleError || error?.code === 'stale') clearPreparedVideo('전술이 변경되어 영상 생성을 취소했습니다');
else { clearPreparedVideo(`영상 생성 실패: ${error.message || error}`); }
} finally {
surface?.dispose(); board.resize(); videoExportActive = false; videoJob = null; renderVideoControls();
}
}
async function sharePreparedVideo() {
ensureVideoFresh();
if (!preparedVideo) { setVideoStatus('먼저 MP4 영상을 만들어 준비하세요'); return; }
try { await shareVideoFile(preparedVideo.file, { navigatorObject: window.navigator, title: state.play.name || 'basket-utils 전술 영상', text: '농구 전술 MP4 영상' }); setVideoStatus('공유창을 열었습니다 · 카카오톡 대화방을 선택해 전송하세요'); }
catch (error) { if (error?.name === 'AbortError') setVideoStatus('영상 파일 공유를 취소했습니다'); else setVideoStatus(error.message || '영상 파일 공유를 지원하지 않습니다'); }
}
function showGate(view = 'auth', message = '') {
cancelActiveDrag();
const gate = document.querySelector('#account-gate'); if (!gate) return;
gate.hidden = false; document.querySelector('.shell').hidden = view !== 'editor';
for (const id of ['auth-panel', 'register-panel', 'pending-panel', 'team-panel', 'play-library-panel', 'admin-panel']) document.querySelector(`#${id}`).hidden = id !== `${view}-panel`;
const operatorFab = document.querySelector('#operator-fab'); const showOperatorFab = Boolean(currentUser?.isOperator && ['teams', 'library'].includes(view)); if (operatorFab) operatorFab.hidden = !showOperatorFab; gate.classList.toggle('has-operator-fab', showOperatorFab);
if (view === 'auth') document.querySelector('#auth-message').textContent = message;
if (view === 'register') document.querySelector('#register-message').textContent = message;
if (view === 'pending') document.querySelector('#pending-message').textContent = message || '운영자 승인이 완료되면 다시 로그인해 주세요.';
if (view === 'teams') { document.querySelector('#team-panel').hidden = false; document.querySelector('#auth-panel').hidden = true; document.querySelector('#register-panel').hidden = true; document.querySelector('#pending-panel').hidden = true; }
if (view === 'library') { document.querySelector('#play-library-panel').hidden = false; document.querySelector('.shell').hidden = true; }
if (view === 'admin') { document.querySelector('#admin-panel').hidden = false; document.querySelector('.shell').hidden = true; }
if (view === 'teams') renderTeamHubView();
if (view === 'editor') gate.hidden = true;
}
function resetTeamHubState() {
teamHubView = 'joined'; teamHubViewUserSelected = false; selectedTeam = null; teamActionView = 'library'; activeOwnerTeamId = null; ownerGeneration += 1; searchGeneration += 1;
document.querySelector('#team-search-form')?.reset(); document.querySelector('#team-search-results')?.replaceChildren(); document.querySelector('#my-join-requests')?.replaceChildren(); document.querySelector('#team-list')?.replaceChildren(); document.querySelector('#team-message')?.replaceChildren(); document.querySelector('#owner-message')?.replaceChildren(); document.querySelector('#retry-teams')?.setAttribute('hidden', '');
renderTeamHubView();
}
function renderTeamHubView() {
const ownerOpen = Boolean(activeOwnerTeamId);
const joined = document.querySelector('#joined-team-panel'); const search = document.querySelector('#search-team-panel'); const create = document.querySelector('#create-team-panel'); const navigation = document.querySelector('#team-navigation');
if (!joined || !search || !create || !navigation) return;
const teamHeading = document.querySelector('#team-panel > .account-heading'); if (teamHeading) teamHeading.hidden = teamHubView === 'detail'; const accountHeader = document.querySelector('#account-header'); const detailBack = document.querySelector('#team-detail-back'); if (accountHeader) accountHeader.classList.toggle('is-team-detail', teamHubView === 'detail'); if (detailBack) detailBack.hidden = teamHubView !== 'detail';
navigation.hidden = teamHubView === 'detail'; joined.hidden = ownerOpen || teamHubView !== 'joined'; search.hidden = ownerOpen || teamHubView !== 'search'; create.hidden = ownerOpen || teamHubView !== 'create';
for (const [id, selected] of [['show-joined-teams', teamHubView === 'joined' || teamHubView === 'detail'], ['show-team-search', teamHubView === 'search'], ['show-team-create', teamHubView === 'create']]) { const button = document.querySelector(`#${id}`); if (!button) continue; button.setAttribute('aria-selected', String(selected)); button.setAttribute('aria-pressed', String(selected)); button.tabIndex = selected ? 0 : -1; }
const selectedPanel = document.querySelector('#team-detail-panel'); const selectedName = document.querySelector('#selected-team-name'); const actionNavigation = document.querySelector('#team-action-navigation'); const management = document.querySelector('#show-team-management'); const approval = document.querySelector('#show-team-approval');
if (selectedPanel) selectedPanel.hidden = teamHubView !== 'detail' || !selectedTeam;
if (selectedName) selectedName.textContent = selectedTeam?.name || '';
if (actionNavigation) actionNavigation.hidden = teamHubView !== 'detail' || !selectedTeam;
if (management || approval) { const canManage = selectedTeam?.role === 'owner'; if (management) { management.hidden = !canManage; management.disabled = !canManage; } if (approval) { approval.hidden = !canManage; approval.disabled = !canManage; } }
for (const [id, selected] of [['show-team-library', teamActionView === 'library'], ['show-team-management', teamActionView === 'management'], ['show-team-approval', teamActionView === 'approval']]) { const button = document.querySelector(`#${id}`); if (!button || button.hidden) continue; button.setAttribute('aria-selected', String(selected)); button.setAttribute('aria-pressed', String(selected)); button.tabIndex = selected ? 0 : -1; }
const libraryAction = document.querySelector('#team-library-action-panel'); const managementAction = document.querySelector('#team-management-action-panel'); const approvalAction = document.querySelector('#team-approval-action-panel');
if (libraryAction) libraryAction.hidden = teamActionView !== 'library';
if (managementAction) managementAction.hidden = teamActionView !== 'management' || selectedTeam?.role !== 'owner';
if (approvalAction) approvalAction.hidden = teamActionView !== 'approval' || selectedTeam?.role !== 'owner';
const ownerMembersPanel = document.querySelector('#owner-members-panel'); const ownerApprovalPanel = document.querySelector('#owner-approval-panel'); if (ownerMembersPanel) ownerMembersPanel.hidden = !ownerOpen; if (ownerApprovalPanel) ownerApprovalPanel.hidden = !ownerOpen;
document.querySelectorAll('#team-list button[data-team-id]').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.teamId === selectedTeam?.id)));
}
function setTeamHubView(view, { userInitiated = false } = {}) {
if (!['joined', 'search', 'create'].includes(view)) return;
if (activeOwnerTeamId) { ownerGeneration += 1; activeOwnerTeamId = null; setOwnerView(false); }
teamHubView = view; if (view !== 'detail') selectedTeam = null; if (userInitiated) teamHubViewUserSelected = true; renderTeamHubView();
}
function setTeamActionView(view, { userInitiated = false } = {}) {
if (!['library', 'management', 'approval'].includes(view) || !selectedTeam || (['management', 'approval'].includes(view) && selectedTeam.role !== 'owner')) return;
teamActionView = view; if (userInitiated) renderTeamHubView();
}
function draftKey() { return currentUser && currentTeam ? `basket-utils:draft:v2:${encodeURIComponent(currentUser.id)}:${encodeURIComponent(currentTeam.id)}` : ''; }
function restoreTeamDraft() {
const key = draftKey(); if (!key || !storage) return '저장 전';
try { const raw = storage.getItem(key); if (!raw) return '저장 전'; state = createAppStateFromPlay(JSON.parse(raw)); return '임시 저장 복원됨'; } catch { return '임시 저장을 복원하지 못했습니다'; }
}
function renderTeamList(teams) {
const list = document.querySelector('#team-list'); list.replaceChildren(); document.querySelector('#retry-teams').hidden = true;
if (!teams.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '아직 팀이 없습니다. 첫 팀을 만들어 시작하세요.'; list.append(empty); return; }
for (const team of teams) { const roleLabel = team.role === 'owner' ? '소유자' : team.role === 'editor' ? '편집자' : '열람자'; const button = document.createElement('button'); button.type = 'button'; button.className = 'team-card team-select team-row'; button.dataset.teamId = team.id; button.dataset.role = team.role; button.setAttribute('aria-label', `${team.name}, 권한 ${roleLabel}`); button.setAttribute('aria-pressed', 'false'); const name = document.createElement('span'); name.className = 'team-name'; name.textContent = team.name; const role = document.createElement('span'); role.className = 'team-role'; role.textContent = roleLabel; button.append(name, role); list.append(button); }
}
function renderTeamDetailTactics(records) {
const list = document.querySelector('#team-detail-tactics-list'); if (!list) return; list.replaceChildren();
if (!records.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '저장된 전술이 없습니다.'; list.append(empty); return; }
for (const record of records) { const row = document.createElement('div'); row.className = 'play-library-row'; const copy = document.createElement('div'); const name = document.createElement('strong'); name.textContent = record.name; const updated = document.createElement('small'); updated.textContent = `수정 ${new Date(record.updatedAt).toLocaleString()}`; copy.append(name, updated); const button = document.createElement('button'); button.type = 'button'; button.className = 'primary'; button.dataset.openDetailPlay = record.id; button.textContent = '열기'; row.append(copy, button); list.append(row); }
}
async function loadTeamDetailTactics(team) {
const generation = ++detailTacticsGeneration; const status = document.querySelector('#team-detail-tactics-status'); const list = document.querySelector('#team-detail-tactics-list');
if (status) status.textContent = '전술 목록을 불러오는 중…'; if (list) list.replaceChildren();
try { const records = await createServerPlayRepository(team.id).list(); if (generation !== detailTacticsGeneration || selectedTeam?.id !== team.id || !currentUser) return; renderTeamDetailTactics(records); if (status) status.textContent = ''; }
catch (error) { if (generation !== detailTacticsGeneration || selectedTeam?.id !== team.id) return; if (status) status.textContent = `전술 목록을 불러오지 못했습니다: ${error.message}`; }
}
async function renderPendingUsers(generation = accountGeneration) {
const target = document.querySelector('#pending-users'); target.replaceChildren();
if (!currentUser?.isOperator) return;
try {
const { users } = await listPendingUsers(); if (generation !== accountGeneration || !currentUser) return;
if (!users.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '승인 대기 사용자가 없습니다.'; target.append(empty); return; }
for (const user of users) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${user.displayName} · ${user.email}`; const button = document.createElement('button'); button.type = 'button'; button.dataset.approveUser = user.id; button.textContent = '승인'; row.append(label, button); target.append(row); }
} catch (error) { const message = document.createElement('p'); message.className = 'account-empty'; message.textContent = error.message; target.append(message); }
}
async function openAdmin(generation = accountGeneration, navigate = true) {
if (!currentUser?.isOperator) { showGate('auth', '운영자 권한이 필요한 페이지입니다.'); if (window.location.pathname === '/admin') history.replaceState({ view: 'auth' }, '', '/'); return; }
operationCoordinator.beginIntent(); videoJob?.cancel(); controller.pause(); searchGeneration += 1; ownerGeneration += 1; activeOwnerTeamId = null;
showGate('admin'); if (navigate && window.location.pathname !== '/admin') history.pushState({ view: 'admin' }, '', '/admin');
await renderPendingUsers(generation);
}
async function searchAndRenderTeams(query) {
const target = document.querySelector('#team-search-results'); target.replaceChildren(); document.querySelector('#join-status').textContent = '';
const generation = ++searchGeneration;
if (query.trim().length < 2) return;
try { const [{ teams }, { requests: joinRequests }, { teams: memberships }] = await Promise.all([searchTeams(query), listJoinRequests(), listTeams()]); if (generation !== searchGeneration || !currentUser) return; const byTeam = new Map(joinRequests.map((request) => [request.teamId, request])); const memberByTeam = new Map(memberships.map((team) => [team.id, team])); for (const team of teams) { const row = document.createElement('div'); row.className = 'team-card search-team-row'; const name = document.createElement('strong'); name.textContent = team.name; const button = document.createElement('button'); button.type = 'button'; button.dataset.joinTeam = team.id; const membership = memberByTeam.get(team.id); const request = byTeam.get(team.id); if (membership) { button.textContent = '가입된 팀'; button.disabled = true; } else if (request?.status === 'pending') { button.textContent = '승인 대기 중'; button.disabled = true; } else if (request?.status === 'approved') { button.textContent = '가입 완료'; button.disabled = true; } else button.textContent = '가입 요청'; row.append(name, button); target.append(row); } renderJoinRequests(joinRequests); if (!teams.length) document.querySelector('#join-status').textContent = '검색된 팀이 없습니다.'; }
catch (error) { if (generation === searchGeneration) document.querySelector('#join-status').textContent = error.message; }
}
function renderJoinRequests(requests) { const target = document.querySelector('#my-join-requests'); target.replaceChildren(); if (!requests.length) return; const heading = document.createElement('strong'); heading.textContent = '내 가입 요청'; target.append(heading); for (const request of requests) { const row = document.createElement('p'); row.className = 'join-request-row'; row.textContent = `${request.teamName || '알 수 없는 팀'} · ${request.status === 'approved' ? '승인됨' : '승인 대기'}`; target.append(row); } }
function setOwnerView(active) {
for (const selector of ['#owner-members-panel', '#owner-approval-panel']) { const panel = document.querySelector(selector); if (panel) panel.hidden = !active; }
if (!active) renderTeamHubView();
}
async function openOwnerPanel(team, action = 'management') { if (team?.role && team.role !== 'owner') return; const ownerTeam = team || selectedTeam; if (!ownerTeam?.id || ownerTeam.role !== 'owner') return; const generation = ++ownerGeneration; selectedTeam = ownerTeam; teamActionView = action; activeOwnerTeamId = ownerTeam.id; document.querySelector('#team-panel').hidden = false; setOwnerView(true); renderTeamHubView(); document.querySelector('#owner-team-name').textContent = ownerTeam.name ? `${ownerTeam.name} 팀` : '팀원 관리'; const requestsTarget = document.querySelector('#owner-requests'); const membersTarget = document.querySelector('#owner-members'); requestsTarget.replaceChildren(); membersTarget.replaceChildren(); document.querySelector('#owner-message').textContent = '팀원 정보를 불러오는 중…'; try { const [{ requests }, { members }] = await Promise.all([listTeamJoinRequests(ownerTeam.id), listTeamMembers(ownerTeam.id)]); if (generation !== ownerGeneration || !currentUser || activeOwnerTeamId !== ownerTeam.id) return; renderOwnerRows(ownerTeam, requests, members); } catch (error) { if (generation === ownerGeneration) document.querySelector('#owner-message').textContent = error.message; } }
function roleSelect(role, dataset) { const select = document.createElement('select'); select.setAttribute('aria-label', '팀원 역할'); for (const [value, label] of [['viewer', '열람자'], ['editor', '편집자']]) { const option = document.createElement('option'); option.value = value; option.textContent = label; select.append(option); } select.value = role; select.dataset.previousRole = role; Object.assign(select.dataset, dataset); return select; }
function renderOwnerRows(team, requests, members) { const requestsTarget = document.querySelector('#owner-requests'); const membersTarget = document.querySelector('#owner-members'); if (!requests.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '가입 승인 대기 요청이 없습니다.'; requestsTarget.append(empty); } for (const request of requests) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${request.user.displayName} · ${request.user.email}`; const select = roleSelect('viewer', {}); const approve = document.createElement('button'); approve.type = 'button'; approve.textContent = '승인'; approve.dataset.approveJoin = request.id; approve.dataset.teamId = team.id; row.append(label, select, approve); requestsTarget.append(row); } if (!members.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '현재 팀원이 없습니다.'; membersTarget.append(empty); } for (const member of members) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${member.user.displayName} · ${member.user.email}`; row.append(label); if (member.role === 'owner') { const owner = document.createElement('small'); owner.textContent = '소유자'; row.append(owner); } else { const select = roleSelect(member.role, { memberRole: member.user.id, teamId: team.id }); const save = document.createElement('button'); save.type = 'button'; save.textContent = '역할 저장'; save.dataset.saveMemberRole = 'true'; row.append(select, save); } membersTarget.append(row); } document.querySelector('#owner-message').textContent = ''; }
async function openTeamHub(message = '', generation = accountGeneration) {
ownerGeneration += 1; detailTacticsGeneration += 1; activeOwnerTeamId = null; searchGeneration += 1; currentTeam = null; selectedTeam = null; teamActionView = 'library'; playRepository = localPlayRepository; document.querySelector('#owner-members-panel').hidden = true; document.querySelector('#owner-approval-panel').hidden = true; document.querySelector('#owner-message').textContent = ''; setOwnerView(false); document.querySelector('#team-message').textContent = message; document.querySelector('#retry-teams').hidden = true;
if (teamHubView === 'detail') { teamHubView = 'joined'; teamHubViewUserSelected = false; }
showGate('teams'); document.querySelector('#team-welcome').textContent = `${currentUser?.displayName || currentUser?.email || ''}님, 사용할 팀을 선택하세요.`; document.querySelector('#team-message').textContent = message;
try { const { teams } = await listTeams(); if (generation !== accountGeneration || !currentUser) return; renderTeamList(teams); if (!teamHubViewUserSelected) setTeamHubView(teams.length ? 'joined' : 'search'); const { requests } = await listJoinRequests(); if (generation === accountGeneration) renderJoinRequests(requests); }
catch (error) { if (generation === accountGeneration) { document.querySelector('#team-message').textContent = `팀 목록을 불러오지 못했습니다: ${error.message}`; document.querySelector('#retry-teams').hidden = false; renderTeamHubView(); } }
}
function renderPlayLibrary(records, draftMessage = '', viewer = false) {
const list = document.querySelector('#play-library-list'); list.replaceChildren();
if (!records.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = viewer ? '저장된 전술이 없습니다.' : '저장된 전술이 없습니다. 아래에서 새 전술을 시작하세요.'; list.append(empty); }
for (const record of records) { const row = document.createElement('div'); row.className = 'play-library-row'; const copy = document.createElement('div'); const name = document.createElement('strong'); name.textContent = record.name; const updated = document.createElement('small'); updated.textContent = `수정 ${new Date(record.updatedAt).toLocaleString()}`; copy.append(name, updated); const button = document.createElement('button'); button.type = 'button'; button.className = 'primary'; button.dataset.openPlay = record.id; button.textContent = '열기'; row.append(copy, button); list.append(row); }
const continueButton = document.querySelector('#continue-draft');
const hasDraft = !viewer && draftMessage === '임시 저장 복원됨'; continueButton.hidden = !hasDraft; continueButton.textContent = hasDraft ? `임시 저장 계속하기 · ${state.play.name}` : '임시 저장 계속하기';
}
async function openTeamLibrary(team, message = '') {
if (!team?.id || !currentUser) return;
if (!document.querySelector('.shell')?.hidden && currentTeam?.id === team.id) saveDraft();
document.querySelector('#continue-draft').hidden = true;
document.querySelector('#new-play-form')?.reset();
const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentTeam = team; playRepository = createServerPlayRepository(team.id); editorHistory.clear(); state = createAppState(); const draftMessage = team.role === 'viewer' ? '저장 전' : restoreTeamDraft(); controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false;
selectedTeam = team; teamActionView = 'library'; document.querySelector('#play-library-title').textContent = `${team.name} · 전술 목록`; document.querySelector('#play-library-welcome').textContent = team.role === 'viewer' ? '저장된 전술을 열람할 수 있습니다.' : '저장된 전술을 열거나 새 전술을 시작하세요.'; document.querySelector('#play-library-status').textContent = '저장된 전술을 불러오는 중…'; document.querySelector('#play-library-message').textContent = message; document.querySelector('#library-new-play').disabled = team.role === 'viewer'; document.querySelector('#play-library-list').replaceChildren(); showGate('library');
try { const records = await playRepository.list(); if (generation !== accountGeneration || currentTeam?.id !== team.id || !currentUser) return; renderPlayLibrary(records, draftMessage, team.role === 'viewer'); document.querySelector('#play-library-status').textContent = ''; } catch (error) { if (generation !== accountGeneration || currentTeam?.id !== team.id) return; document.querySelector('#play-library-status').textContent = `저장 목록을 불러오지 못했습니다: ${error.message}`; const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'account-secondary'; retry.id = 'retry-play-library'; retry.textContent = '다시 시도'; document.querySelector('#play-library-list').append(retry); }
}
async function selectTeam(team) {
if (!team?.id || !currentUser) return;
selectedTeam = team; teamActionView = 'library'; teamHubView = 'detail'; teamHubViewUserSelected = true; renderTeamHubView(); loadTeamDetailTactics(team);
}
function enterEditor(nextState, status = '이동 행동 편집 단계') {
nextState = { ...nextState, mode: nextState.mode || 'move', selectedAction: -1, view: 'tactical', boardPreview: undefined };
panel = 'court'; gazePick = null;
loadGeneration += 1; editorHistory.clear(); state = nextState; controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; const menu = document.querySelector('#project-menu'); if (menu) { menu.hidden = true; delete menu.dataset.view; } document.querySelector('#toggle-menu')?.setAttribute('aria-expanded', 'false'); document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType || 'man-to-man'; document.querySelector('#team-context').textContent = currentTeam?.name || ''; document.querySelector('#save-status').textContent = '전술 편집 중'; const roleBanner = document.querySelector('#editor-role-banner'); roleBanner.hidden = currentTeam?.role !== 'viewer'; roleBanner.textContent = currentTeam?.role === 'viewer' ? '열람자 권한: 팀 전술을 조회할 수 있으며, 변경 내용을 서버에 저장할 수 없습니다.' : ''; showGate('editor'); renderUi(); document.querySelector('#status').textContent = status; refreshSavedPlays(state.play.id).catch(() => {});
}
async function openLibraryPlay(id, statusSelector = '#play-library-status') {
if (!id || !currentTeam || !currentUser) return;
const generation = ++loadGeneration; const accountAtRequest = accountGeneration; const teamAtRequest = currentTeam.id; const userAtRequest = currentUser.id; const repository = playRepository; const status = document.querySelector(statusSelector); status.textContent = '전술을 불러오는 중…';
try { const play = await repository.get(id); if (generation !== loadGeneration || accountGeneration !== accountAtRequest || currentTeam?.id !== teamAtRequest || currentUser?.id !== userAtRequest || playRepository !== repository) return; if (!play) throw new Error('저장된 전술을 찾을 수 없습니다'); enterEditor(createAppStateFromPlay(play), '저장된 전술을 불러왔습니다'); }
catch (error) { if (generation === loadGeneration && accountGeneration === accountAtRequest && currentTeam?.id === teamAtRequest && currentUser?.id === userAtRequest && playRepository === repository) status.textContent = `전술을 열지 못했습니다: ${error.message}`; }
}
function startNewFromLibrary(name, defenseType) {
if (currentTeam?.role === 'viewer') { document.querySelector('#play-library-message').textContent = '열람자 권한에서는 새 전술을 만들 수 없습니다.'; return; }
const next = createAppState(String(name || '').trim() || '새 전술', defenseType || 'man-to-man'); enterEditor({ ...next, selectedPlayerId: 'offense-1' }); document.querySelector('#new-play-form')?.reset(); saveDraft();
}
async function initializeAccount() {
sessionActivity.start();
const generation = accountGeneration;
try { const result = await getCurrentUser(); if (generation !== accountGeneration) return; if (currentUser?.id !== result.user.id) resetTeamHubState(); currentUser = result.user; if (window.location.pathname === '/admin') { if (currentUser.isOperator) await openAdmin(generation); else { history.replaceState({ view: 'teams' }, '', '/'); await openTeamHub('운영자만 이용할 수 있는 페이지입니다.', ++accountGeneration); } } else await openTeamHub('', generation); }
catch (error) { if (generation !== accountGeneration) return; sessionActivity.stop(); if (!(error instanceof ApiError) || error.status !== 401) document.querySelector('#auth-message').textContent = '서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.'; showGate('auth', document.querySelector('#auth-message').textContent); }
}
async function logoutFromUi() {
sessionActivity.stop();
const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; resetTeamHubState(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentUser = null; currentTeam = null; playRepository = localPlayRepository; operationCoordinator.beginIntent(); document.querySelector('.shell').hidden = true; showGate('auth', '로그아웃 중…'); document.querySelectorAll('#login-form button,#show-register').forEach((control) => { control.disabled = true; });
try { await logoutAccount(); if (generation === accountGeneration) showGate('auth'); }
catch (error) { if (generation === accountGeneration) document.querySelector('#auth-message').textContent = `로그아웃 요청을 완료하지 못했습니다: ${error.message}`; }
finally { if (generation === accountGeneration) document.querySelectorAll('#login-form button,#show-register').forEach((control) => { control.disabled = false; }); }
}
function downloadPreparedVideo() {
ensureVideoFresh();
if (!preparedVideo) { setVideoStatus('먼저 MP4 영상을 만들어 준비하세요'); return; }
try { downloadVideoFile(preparedVideo.file, { fileName: preparedVideo.file.name }); setVideoStatus('MP4 다운로드를 시작했습니다 · 카카오톡에 직접 첨부할 수 있습니다'); }
catch (error) { setVideoStatus(error.message || '영상 다운로드를 준비하지 못했습니다'); }
}
async function refreshSavedPlays(selectedId = '', canSelect = () => true, options = {}) {
const repository = playRepository; const userAtRequest = currentUser?.id; const teamAtRequest = currentTeam?.id; const nonInvasive = options.nonInvasive === true; const generation = nonInvasive ? ++passiveListGeneration : ++savedListGeneration; const activeGenerationAtStart = savedListGeneration; const isCurrent = () => nonInvasive ? generation === passiveListGeneration && activeGenerationAtStart === savedListGeneration && currentUser?.id === userAtRequest && currentTeam?.id === teamAtRequest && playRepository === repository : generation === savedListGeneration && currentUser?.id === userAtRequest && currentTeam?.id === teamAtRequest && playRepository === repository; const select = document.querySelector('#saved-plays'); const load = document.querySelector('#load-play');
try {
const records = await repository.list(); if (!isCurrent()) return false; const preservedSelection = nonInvasive ? select.value : ''; const preservedLoadDisabled = nonInvasive ? load.disabled : false; select.replaceChildren(); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.selected = nonInvasive ? !preservedSelection : !selectedId; placeholder.textContent = records.length ? '저장된 전술 선택' : '저장된 전술 없음'; select.append(placeholder);
for (const record of records) { const option = document.createElement('option'); option.value = record.id; option.textContent = `${record.name} · ${new Date(record.updatedAt).toLocaleString()}`; select.append(option); }
if (nonInvasive) { if (preservedSelection && records.some((record) => record.id === preservedSelection)) select.value = preservedSelection; load.disabled = preservedLoadDisabled; } else { if (selectedId && canSelect() && records.some((record) => record.id === selectedId)) select.value = selectedId; load.disabled = !select.value; } return true;
} catch (error) { if (!isCurrent() || nonInvasive) return false; select.replaceChildren(); const option = document.createElement('option'); option.value = ''; option.disabled = true; option.selected = true; option.textContent = '저장 목록을 사용할 수 없음'; select.append(option); load.disabled = true; throw error; }
}
async function saveCurrentPlay() {
if (currentTeam?.role === 'viewer') { document.querySelector('#status').textContent = '열람자 권한에서는 전술을 저장할 수 없습니다. 팀 소유자에게 편집자 권한을 요청하세요.'; return; }
const token = operationCoordinator.beginIntent(); const repository = playRepository; const teamAtRequest = currentTeam?.id;
const playAtRequest = state.play; const nameAtRequest = String(document.querySelector('#play-name').value || '').trim() || '새 전술'; const nextPlay = structuredClone(playAtRequest); nextPlay.name = nameAtRequest;
const isSaveStillRelevant = () => operationCoordinator.isCurrent(token) && currentTeam?.id === teamAtRequest && state.play === playAtRequest && (String(document.querySelector('#play-name').value || '').trim() || '새 전술') === nameAtRequest;
try { await operationCoordinator.enqueueSave(nextPlay.id, () => repository.save(nextPlay)); } catch (error) { if (isSaveStillRelevant() && currentTeam?.id === teamAtRequest) document.querySelector('#status').textContent = `전술 저장 실패: ${error.message}`; return; }
if (!isSaveStillRelevant()) { try { await refreshSavedPlays('', () => true, { nonInvasive: true }); } catch { /* stale save must not alter current UI */ } return; }
state.play.name = nextPlay.name; document.querySelector('#play-name').value = state.play.name; renderUi();
try { await refreshSavedPlays(isSaveStillRelevant() ? nextPlay.id : '', isSaveStillRelevant); if (isSaveStillRelevant()) document.querySelector('#status').textContent = '전술을 저장했습니다'; } catch (error) { if (isSaveStillRelevant()) document.querySelector('#status').textContent = `전술은 저장했지만 목록 갱신에 실패했습니다: ${error.message}`; }
}
async function loadSelectedPlay() {
const id = document.querySelector('#saved-plays').value; if (!id) return; const repository = playRepository; const teamAtRequest = currentTeam?.id; const token = operationCoordinator.beginIntent(); const generation = ++loadGeneration; const playAtRequest = state.play; const nameAtRequest = String(document.querySelector('#play-name').value || '').trim() || '새 전술';
const isLoadStillRelevant = () => operationCoordinator.isCurrent(token) && generation === loadGeneration && state.play === playAtRequest && (String(document.querySelector('#play-name').value || '').trim() || '새 전술') === nameAtRequest;
try { const play = await repository.get(id); if (!isLoadStillRelevant() || currentTeam?.id !== teamAtRequest) return; if (!play) { document.querySelector('#status').textContent = '저장된 전술을 찾을 수 없습니다'; return; } const nextState = createAppStateFromPlay(play); if (!isLoadStillRelevant()) return; editorHistory.clear(); state = nextState; document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType || 'man-to-man'; saveDraft(); controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; renderUi(); document.querySelector('#status').textContent = '저장된 전술을 불러왔습니다'; } catch (error) { if (isLoadStillRelevant() && currentTeam?.id === teamAtRequest) document.querySelector('#status').textContent = `전술 불러오기 실패: ${error.message}`; }
}
function selectedTrack() { return state.play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId); }
function setState(next, status = '', record = true) {
gazePick = null;
if (record) editorHistory.record(state, next);
if (next.play !== state.play) operationCoordinator.beginIntent();
state = next;
saveDraft();
controller = createPlaybackController(state.play);
playbackSession = false;
resetPreview = false;
if (status) document.querySelector('#status').textContent = status;
renderUi();
}
function updatePlay(play, status, selectedAction = -1) { setState({ ...commitActionEdit(state, play), selectedAction }, status); }
function addPassForTarget(targetId) {
const sequence = state.play.sequences[state.selectedSequence]; const ownerId = finalBallOwner(sequence); const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); const target = state.play.players.find((player) => player.id === targetId);
if (!ownerId || selectedPlayer?.id !== ownerId || selectedPlayer.team !== 'offense' || !target || target.team !== 'offense' || target.id === ownerId) return;
const play = addAction(state.play, state.selectedSequence, ownerId, null, { type: 'pass', targetPlayerId: targetId });
if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '패스는 현재 공 소유 공격 선수 → 다른 공격 선수만 가능합니다';
else { state = { ...state, selectedPlayerId: ownerId, mode: 'move' }; const track = play.sequences[state.selectedSequence].tracks.find(track => track.playerId === ownerId); updatePlay(play, '패스 행동 추가', track.actions.length - 1); }
}
function addScreenForTarget(targetId) {
const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); const target = state.play.players.find((player) => player.id === targetId); const sequence = state.play.sequences[state.selectedSequence];
if (!screener || screener.team !== 'offense' || finalBallOwner(sequence) === screener.id) { document.querySelector('#status').textContent = '스크린은 공을 가지지 않은 공격 선수만 가능합니다'; return; }
if (!target || target.team !== 'defense') { document.querySelector('#status').textContent = '스크린 대상은 수비 선수여야 합니다'; return; }
const play = addScreenAction(state.play, state.selectedSequence, screener.id, target.id);
if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '스크린 대상과 너무 가까워 배치할 수 없습니다'; else { state = { ...state, mode: 'move' }; const track = play.sequences[state.selectedSequence].tracks.find(track => track.playerId === screener.id); updatePlay(play, '스크린 행동 추가', track.actions.length - 1); }
}
function addShootForOwner() {
const sequenceIndex = state.selectedSequence; if (state.mode === 'start' || sequenceIndex !== state.play.sequences.length - 1) { document.querySelector('#status').textContent = '슛은 마지막 단계에서만 가능합니다'; return; }
const ownerId = finalBallOwner(state.play.sequences[sequenceIndex]); const owner = state.play.players.find((player) => player.id === ownerId);
if (!owner || owner.team !== 'offense') { document.querySelector('#status').textContent = '슛을 시도할 공 소유 공격 선수가 없습니다'; return; }
const workingState = { ...state, mode: 'move', selectedSequence: sequenceIndex, selectedPlayerId: ownerId, selectedAction: -1 }; const play = addShootAction(state.play, sequenceIndex, ownerId);
if (play === state.play) { state = workingState; renderUi(); document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동은 전술당 1개만 가능합니다' : '슛은 마지막 단계의 공 소유 선수만 가능합니다'; return; }
state = workingState; const track = play.sequences[sequenceIndex].tracks.find((candidate) => candidate.playerId === ownerId); updatePlay(play, '슛 행동 추가', track.actions.length - 1);
}
function removeShootAction() {
const shot = state.play.sequences.flatMap((sequence, sequenceIndex) => sequence.tracks.flatMap((track) => track.actions.map((action, actionIndex) => ({ action, actionIndex, playerId: track.playerId, sequenceIndex })))).find(({ action }) => action.type === 'shoot');
if (!shot) return;
const play = removeAction(state.play, shot.sequenceIndex, shot.playerId, shot.actionIndex);
if (play === state.play) return;
setState({ ...commitActionEdit(state, play, shot.sequenceIndex, shot.playerId), selectedAction: -1 }, '슛 행동 삭제');
}
function renderUi() {
const readOnly = currentTeam?.role === 'viewer';
for (const id of ['save-play', 'import-play', 'import-file', 'new-play']) { const element = document.querySelector('#' + id); if (element) element.disabled = readOnly; }
const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId);
const sequence = state.play.sequences[state.selectedSequence] || state.play.sequences[0];
const playHasShoot = hasShoot(state.play);
const startingOwner = state.play.players.find((player) => player.id === sequence?.ballOwnerId);
const finalOwnerId = finalBallOwner(sequence); const finalOwner = state.play.players.find((player) => player.id === finalOwnerId); const hasPass = finalOwnerId && finalOwnerId !== sequence?.ballOwnerId;
document.querySelector('#canvas-step').textContent = String(state.selectedSequence + 1).padStart(2, '0');
if (currentTeam) document.querySelector('#team-context').textContent = currentTeam.name;
document.querySelector('#open-team-hub').textContent = '전술 목록';
document.querySelector('#selected-label').textContent = selectedPlayer ? `${selectedPlayer.team === 'offense' ? 'O' : 'D'}${selectedPlayer.number}` : '';
document.querySelector('#roster').innerHTML = ['offense', 'defense'].map((team) => `<div class="team-label">${team === 'offense' ? 'OFFENSE' : 'DEFENSE'}</div>${state.play.players.filter((player) => player.team === team).map((player) => { const startMark = hasPass && player.id === sequence?.ballOwnerId ? '<span class="ball-mark">START</span>' : ''; const finalMark = player.id === finalOwnerId ? '<span class="ball-mark">BALL</span>' : ''; return `<button class="roster-player ${player.id === state.selectedPlayerId ? 'selected' : ''} ${player.id === finalOwnerId ? 'owns-ball' : ''}" data-player="${player.id}"><span class="dot ${team}"></span>${team === 'offense' ? 'O' : 'D'}${player.number}${startMark}${finalMark}<small>${player.location.x.toFixed(1)}, ${player.location.z.toFixed(1)}</small></button>`; }).join('')}`).join('');
document.querySelector('#ball-owner-label').textContent = hasPass ? `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'} · 패스 후: ${finalOwner ? `O${finalOwner.number}` : '없음'}` : `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'}`;
const ballOwnerButton = document.querySelector('#set-ball-owner'); ballOwnerButton.disabled = playHasShoot || selectedPlayer?.team !== 'offense' || selectedPlayer?.id === sequence?.ballOwnerId; ballOwnerButton.textContent = selectedPlayer?.id === sequence?.ballOwnerId ? '현재 시작 공 소유자' : '선택 선수를 시작 공 소유자로';
const helpCopy = playHasShoot
? '<strong>슛</strong><br />슛이 마지막 위치에 추가되었습니다. 기존 이동과 시선은 행동 패널에서 수정할 수 있습니다. 새 행동을 추가하려면 슛을 삭제하세요.'
: state.mode === 'start'
? '<strong>시작 위치 설정</strong><br />선수를 고른 뒤 코트를 클릭해 시작 위치를 배치하세요.<br />이 단계의 클릭은 행동으로 저장되지 않습니다.<br /><b>배치가 끝나면 이동를 누르세요.</b>'
: state.mode === 'move'
? '<strong>이동 경로 설정</strong><br />선수를 고른 뒤 코트를 연속 클릭해 행동을 만드세요.<br />시작 위치를 바꾸려면 시작 배치로 돌아가세요.'
: state.mode === 'pass' ? '<strong>패스</strong><br />공 소유 공격 선수를 선택한 뒤 다른 공격 선수를 클릭해 수신자를 고르세요.'
: state.mode === 'screen' ? '<strong>스크린</strong><br />볼을 소유하지 않은 공격 선수를 고른 뒤 수비 선수를 클릭하세요.'
: '<strong>시선 설정</strong><br />행동을 고른 뒤 선수나 코트를 클릭하면 시선이 저장됩니다.';
document.querySelector('#help-copy').innerHTML = helpCopy;
const sequenceNav = document.querySelector('#sequences'); sequenceNav.replaceChildren(); state.play.sequences.forEach((sequence, index) => { const button = document.createElement('button'); button.className = `sequence-tab ${index === state.selectedSequence ? 'selected' : ''}`; button.dataset.sequence = String(index); decorateSequenceButton(button, sequence, index, state.play.players); sequenceNav.append(button); }); const sequenceControls = document.querySelector('#sequence-controls'); sequenceControls.replaceChildren(); const addSequenceButton = document.createElement('button'); addSequenceButton.id = 'add-sequence'; addSequenceButton.className = 'add-sequence'; addSequenceButton.textContent = ''; addSequenceButton.setAttribute('aria-label', '단계 추가'); sequenceControls.append(addSequenceButton); const deleteSequenceButton = document.createElement('button'); deleteSequenceButton.id = 'delete-sequence'; deleteSequenceButton.className = 'danger'; deleteSequenceButton.textContent = '삭제'; deleteSequenceButton.setAttribute('aria-label', '현재 단계 삭제'); sequenceControls.append(deleteSequenceButton);
const track = selectedTrack();
document.querySelector('#track-duration').textContent = track ? `${track.actions.length}개 · ${scheduledTrackDuration(sequence, track, 4.5, state.play.players).toFixed(1)}s` : '';
const emptyCopy = state.mode === 'start' ? '시작 위치를 설정 중입니다.<br />이 단계에서는 행동이 생성되지 않습니다.' : state.mode === 'screen' ? '아직 스크린 행동이 없습니다.<br />수비 선수를 선택해 Screen을 만드세요.' : '아직 행동이 없습니다.<br />코트 위를 클릭해 경로를 만드세요.';
document.querySelector('#actions').innerHTML = track?.actions.length ? track.actions.map((action, index) => { const target = state.play.players.find((player) => player.id === action.targetPlayerId); return `<button class="action-row ${index === state.selectedAction ? 'selected' : ''}" data-action="${index}"><span>${index + 1}</span><b>${({ move: '이동', pass: '패스', screen: '스크린', shoot: '슛' }[action.type])}${target ? ` → ${target.team === 'offense' ? 'O' : 'D'}${target.number}` : ''}</b><small>${action.location.x.toFixed(1)}, ${action.location.z.toFixed(1)}${action.lookAt ? ` · ${{ ball: '공', rim: '림', player: '선수', location: '지점', movement: '이동 방향' }[action.lookAt.type]}` : ''}</small></button>`; }).join('') : `<div class="empty">${emptyCopy}</div>`;
const canUndo = editorHistory.canUndo; const canRedo = editorHistory.canRedo;
document.querySelector('#delete-action').disabled = state.mode === 'start' || state.selectedAction < 0;
const shootButton = document.querySelector('#shoot-action'); shootButton.hidden = selectedPlayer?.id !== finalOwnerId;
shootButton.disabled = playHasShoot || state.mode === 'start' || state.selectedSequence !== state.play.sequences.length - 1;
const removeShootButton = document.querySelector('#remove-shoot-action'); removeShootButton.hidden = !playHasShoot;
const passButton = document.querySelector('[data-mode="pass"]'); const canPass = selectedPlayer?.team === 'offense' && selectedPlayer.id === finalOwnerId;
passButton.hidden = !canPass; passButton.disabled = playHasShoot || !canPass;
document.querySelector('#add-sequence').disabled = playHasShoot;
document.querySelector('#delete-sequence').disabled = playHasShoot;
document.querySelectorAll('[data-mode]').forEach((button) => { button.disabled = playHasShoot || (button.dataset.mode === 'pass' && !canPass); });
document.querySelector('#undo').disabled = !canUndo; document.querySelector('#redo').disabled = !canRedo;
document.querySelectorAll('[data-mode]').forEach((button) => button.classList.toggle('active', button.dataset.mode === state.mode));
document.querySelectorAll('[data-sequence]').forEach((button) => button.classList.toggle('selected', Number(button.dataset.sequence) === state.selectedSequence));
document.querySelector('#tactical').classList.toggle('active', state.view === 'tactical'); document.querySelector('#pov').classList.toggle('active', state.view === 'pov');
const action = track?.actions[state.selectedAction];
document.querySelector('#action-properties').hidden = !action;
document.querySelector('#delete-action').hidden = !action;
document.querySelector('#action-properties').disabled = !action || action.type === 'shoot';
document.querySelector('#gaze').value = action?.lookAt?.type || 'auto';
document.querySelector('#cancel-gaze').hidden = !gazePick;
document.querySelector('#gaze-description').textContent = playHasShoot ? '기존 이동·시선은 수정할 수 있습니다. 슛의 시선은 림으로 고정됩니다.' : gazePick ? '코트에서 시선 대상을 선택하세요. Esc로 취소합니다.' : action ? (action.lookAt?.type === 'player' ? '선택 대상: ' + action.lookAt.targetId.replace('offense-', 'O').replace('defense-', 'D') : '자동 시선은 행동과 공 소유 상태를 따릅니다.') : '행동을 선택하세요.';
for (const axis of ['x', 'z']) { const input = document.querySelector('#action-' + axis); input.value = action?.location[axis] ?? ''; input.disabled = action?.type !== 'move'; }
document.querySelector('#apply-position').disabled = action?.type !== 'move';
document.querySelector('#scrubber').max = totalDuration(state.play);
document.querySelector('#data-preview').textContent = serializePlay(state.play);
const shell = document.querySelector('.shell'); shell.dataset.panel = panel; shell.dataset.mobileFocus = mobileFocusActive ? 'true' : 'false'; shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed'; updateOrientationGate(); board.setMobileFocus?.(mobileFocusActive);
document.querySelectorAll('button[data-panel]').forEach(button => { const active = button.dataset.panel === panel; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); });
document.querySelector('#toggle-focus-actions')?.setAttribute('aria-expanded', String(mobileFocusActive && panel === 'actions'));
ensureVideoFresh(); renderVideoControls();
}
app.addEventListener('submit', async (event) => {
event.preventDefault(); const form = event.target;
if (form.id === 'login-form') {
const generation = ++accountGeneration; const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true;
try { const result = await loginAccount(data.get('email'), data.get('password')); if (generation !== accountGeneration) return; if (currentUser?.id !== result.user.id) resetTeamHubState(); currentUser = result.user; sessionActivity.stop(); sessionActivity.start(); form.reset(); if (window.location.pathname === '/admin') { if (currentUser.isOperator) await openAdmin(generation, false); else { history.replaceState({ view: 'teams' }, '', '/'); await openTeamHub('운영자만 이용할 수 있는 페이지입니다.', ++accountGeneration); } } else await openTeamHub('', generation); }
catch (error) { if (generation === accountGeneration) showGate(error.code === 'approval_required' ? 'pending' : 'auth', error.message); }
finally { button.disabled = false; }
} else if (form.id === 'register-form') {
const generation = accountGeneration; const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true;
try { const result = await registerAccount(data.get('email'), data.get('password'), data.get('displayName')); if (generation !== accountGeneration) return; form.reset(); showGate('pending', result.message); }
catch (error) { if (generation === accountGeneration) showGate('register', error.message); }
finally { button.disabled = false; }
} else if (form.id === 'team-form') {
const generation = accountGeneration; const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true;
try { const result = await createTeam(data.get('name')); if (generation !== accountGeneration) return; form.reset(); await selectTeam(result.team); }
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = error.message; }
finally { button.disabled = false; }
} else if (form.id === 'team-search-form') { await searchAndRenderTeams(new FormData(form).get('q')); }
else if (form.id === 'new-play-form') { const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true; try { startNewFromLibrary(data.get('name'), data.get('defenseType')); } finally { button.disabled = currentTeam?.role === 'viewer'; } }
});
app.addEventListener('click', (event) => {
if (event.target.closest('#toggle-focus-playback')) {
if (!mobileFocusActive) return;
focusPlaybackOpen = !focusPlaybackOpen;
const shell = document.querySelector('.shell');
if (shell) shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed';
event.target.closest('#toggle-focus-playback').setAttribute('aria-expanded', String(focusPlaybackOpen));
return;
}
if (event.target.closest('#toggle-focus-actions')) {
if (!mobileFocusActive) return;
panel = panel === 'actions' ? 'court' : 'actions';
renderUi();
return;
}
if (event.target.closest('#show-register')) { showGate('register'); return; }
if (event.target.closest('#show-login') || event.target.closest('#pending-login')) { showGate('auth'); return; }
if (event.target.closest('#team-detail-back')) { detailTacticsGeneration += 1; ownerGeneration += 1; activeOwnerTeamId = null; currentTeam = null; playRepository = localPlayRepository; teamActionView = 'library'; setOwnerView(false); setTeamHubView('joined', { userInitiated: true }); return; }
if (event.target.closest('#show-joined-teams')) { setTeamHubView('joined', { userInitiated: true }); return; }
if (event.target.closest('#show-team-search')) { setTeamHubView('search', { userInitiated: true }); return; }
if (event.target.closest('#show-team-create')) { setTeamHubView('create', { userInitiated: true }); return; }
if (event.target.closest('#show-team-library')) { setTeamActionView('library', { userInitiated: true }); return; }
if (event.target.closest('#show-team-management')) { if (selectedTeam?.role === 'owner') openOwnerPanel(selectedTeam); return; }
if (event.target.closest('#show-team-approval')) { if (selectedTeam?.role === 'owner') openOwnerPanel(selectedTeam, 'approval'); return; }
if (event.target.closest('#refresh-teams') || event.target.closest('#retry-teams')) { setOwnerView(false); openTeamHub('', ++accountGeneration); return; }
if (event.target.closest('[data-approve-join]')) { const button = event.target.closest('[data-approve-join]'); const ownerTeamId = button.dataset.teamId; const generation = ownerGeneration; const role = button.parentElement.querySelector('select')?.value || 'viewer'; button.disabled = true; approveTeamJoin(ownerTeamId, button.dataset.approveJoin, role).then(() => { if (generation === ownerGeneration && activeOwnerTeamId === ownerTeamId) return openOwnerPanel(selectedTeam, teamActionView); }).catch((error) => { if (generation === ownerGeneration) { button.disabled = false; document.querySelector('#owner-message').textContent = error.message; } }); return; }
if (event.target.closest('[data-save-member-role]')) { const button = event.target.closest('[data-save-member-role]'); const select = button.parentElement.querySelector('select[data-member-role]'); const teamId = select?.dataset.teamId; const userId = select?.dataset.memberRole; const previous = select?.dataset.previousRole; const requestedRole = select?.value; const generation = ownerGeneration; if (!select || !teamId || !userId) return; button.disabled = true; select.disabled = true; changeTeamMemberRole(teamId, userId, requestedRole).then(() => { if (generation === ownerGeneration) { select.dataset.previousRole = requestedRole; document.querySelector('#owner-message').textContent = '역할을 저장했습니다.'; } }).catch((error) => { if (generation === ownerGeneration) { select.value = previous; document.querySelector('#owner-message').textContent = error.message; } }).finally(() => { if (generation === ownerGeneration) { button.disabled = false; select.disabled = false; } }); return; }
if (event.target.closest('#admin-back')) { accountGeneration += 1; searchGeneration += 1; setOwnerView(false); history.pushState({ view: 'teams' }, '', '/'); openTeamHub('', accountGeneration); return; }
if (event.target.closest('[data-join-team]')) { const button = event.target.closest('[data-join-team]'); const generation = searchGeneration; button.disabled = true; requestTeamJoin(button.dataset.joinTeam).then(() => { if (generation !== searchGeneration) return; button.textContent = '승인 대기 중'; document.querySelector('#join-status').textContent = '가입 요청을 보냈습니다. 팀 소유자의 승인을 기다려 주세요.'; return searchAndRenderTeams(document.querySelector('#team-search-form input[name="q"]').value); }).catch((error) => { if (generation === searchGeneration) { button.disabled = false; document.querySelector('#join-status').textContent = error.message; } }); return; }
if (event.target.closest('#team-logout') || event.target.closest('#logout')) { document.querySelector('#team-search-results').replaceChildren(); document.querySelector('#my-join-requests').replaceChildren(); document.querySelector('#join-status').textContent = ''; document.querySelector('#team-search-form input[name="q"]').value = ''; logoutFromUi(); return; }
const teamCard = event.target.closest('button.team-select[data-team-id]'); if (teamCard) { const team = { id: teamCard.dataset.teamId, name: teamCard.querySelector('.team-name')?.textContent || teamCard.querySelector('strong')?.textContent || '', role: teamCard.dataset.role || 'viewer' }; selectTeam(team); return; }
if (event.target.closest('[data-approve-user]')) { const generation = accountGeneration; const button = event.target.closest('[data-approve-user]'); button.disabled = true; approveUser(button.dataset.approveUser).then(() => { if (generation === accountGeneration) return renderPendingUsers(generation); }).catch((error) => { if (generation === accountGeneration) (document.querySelector('#admin-message') || document.querySelector('#team-message')).textContent = error.message; }).finally(() => { if (generation === accountGeneration) button.disabled = false; }); return; }
if (event.target.closest('#open-team-hub')) { operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); if (currentTeam) { if (!document.querySelector('.shell')?.hidden) saveDraft(); const team = currentTeam; accountGeneration += 1; loadGeneration += 1; document.querySelector('.shell').hidden = true; showGate('teams'); selectTeam(team); } else { const generation = ++accountGeneration; document.querySelector('.shell').hidden = true; openTeamHub('', generation); } return; }
if (event.target.closest('[data-open-play]')) { openLibraryPlay(event.target.closest('[data-open-play]').dataset.openPlay); return; }
if (event.target.closest('[data-open-detail-play]')) { const playId = event.target.closest('[data-open-detail-play]').dataset.openDetailPlay; const team = selectedTeam; if (team) { currentTeam = team; playRepository = createServerPlayRepository(team.id); openLibraryPlay(playId, '#team-detail-tactics-status'); } return; }
if (event.target.closest('#continue-draft')) { if (currentTeam?.role === 'viewer' || !currentTeam) return; enterEditor(state, '임시 저장 전술을 계속 편집합니다'); return; }
if (event.target.closest('#retry-play-library')) { openTeamLibrary(currentTeam); return; }
if (event.target.closest('#library-back-teams')) { operationCoordinator.beginIntent(); loadGeneration += 1; const team = selectedTeam || currentTeam; const generation = ++accountGeneration; currentTeam = null; activeOwnerTeamId = null; teamActionView = 'library'; playRepository = localPlayRepository; document.querySelector('.shell').hidden = true; if (team) { selectedTeam = team; teamHubView = 'detail'; teamHubViewUserSelected = true; showGate('teams'); renderTeamHubView(); } else { selectedTeam = null; openTeamHub('', generation); } return; }
if (event.target.closest('#video-share-open')) { const menu = document.querySelector('#project-menu'); menu.dataset.view = 'video'; menu.hidden = false; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'true'); document.querySelector('#export-video').focus(); return; }
if (event.target.closest('#close-video-tools')) { const menu = document.querySelector('#project-menu'); menu.hidden = true; delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); document.querySelector('#video-share-open').focus(); return; }
if (event.target.closest('#toggle-menu')) { const menu = document.querySelector('#project-menu'); const open = menu.hidden; menu.hidden = !open; if (open) delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', String(open)); return; }
if (event.target.closest('#cancel-gaze')) { gazePick = null; renderUi(); document.querySelector('#status').textContent = '시선 선택 취소'; return; }
const panelButton = event.target.closest('button[data-panel]'); if (panelButton) { panel = panel === panelButton.dataset.panel ? 'court' : panelButton.dataset.panel; renderUi(); return; }
if (event.target.closest('#apply-position')) { const x = Number(document.querySelector('#action-x').value); const z = Number(document.querySelector('#action-z').value); updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: { x, z } }), '이동 위치 수정', state.selectedAction); return; }
if (event.target.closest('#loop')) { const button = document.querySelector('#loop'); const enabled = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(enabled)); controller.setLoop(enabled); return; }
if (event.target.closest('#previous-step') || event.target.closest('#next-step')) { const current = controller.sample().sequenceIndex; const next = Math.max(0, Math.min(state.play.sequences.length - 1, current + (event.target.closest('#next-step') ? 1 : -1))); seekTo(state.play.sequences.slice(0, next).reduce((time, sequence) => time + sequenceDuration(sequence, 4.5, state.play.players), 0)); return; }
if (event.target.closest('#export-play')) { const play = structuredClone(state.play); play.name = document.querySelector('#play-name').value.trim() || '새 전술'; const url = URL.createObjectURL(new Blob([JSON.stringify(play, null, 2)], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = 'basket-utils.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return; }
if (event.target.closest('#export-video')) { startVideoExport(); return; }
if (event.target.closest('#cancel-video')) { videoJob?.cancel(); return; }
if (event.target.closest('#share-video')) { sharePreparedVideo(); return; }
if (event.target.closest('#download-video')) { downloadPreparedVideo(); return; }
if (event.target.closest('#import-play')) { document.querySelector('#import-file').click(); return; }
if (event.target.closest('#save-play')) { saveCurrentPlay(); return; }
if (event.target.closest('#load-play')) { loadSelectedPlay(); return; }
if (event.target.closest('#shoot-action')) { addShootForOwner(); return; }
if (event.target.closest('#remove-shoot-action')) { removeShootAction(); return; }
if (event.target.closest('#undo')) { const next = editorHistory.undo(state); if (next !== state) setState(next, '실행 취소', false); return; }
if (event.target.closest('#redo')) { const next = editorHistory.redo(state); if (next !== state) setState(next, '다시 실행', false); return; }
if (event.target.closest('#set-ball-owner')) { const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (selectedPlayer?.team === 'offense') { const play = setSequenceBallOwner(state.play, state.selectedSequence, selectedPlayer.id); if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '먼저 패스 행동을 삭제하세요'; else setState({ ...state, play, mode: 'move', selectedAction: -1 }, `Sequence ${state.selectedSequence + 1} 공 소유자 O${selectedPlayer.number}`); } return; }
const playerButton = event.target.closest('[data-player]');
if (playerButton) { const playerId = playerButton.dataset.player; if (gazePick) { if (gazePick === 'player') applyGaze({ type: 'player', targetId: playerId }); return; } if (state.mode === 'pass') addPassForTarget(playerId); else if (state.mode === 'screen') addScreenForTarget(playerId); else if (state.mode === 'lookAt' && state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; const lookAt = normalizeLookAt({ type: 'player', targetId: playerId }, state.selectedPlayerId, play.players); if (action && lookAt) { action.lookAt = lookAt; updatePlay(play, '선수 대상 시선 저장', state.selectedAction); } } else { playbackSession = false; resetPreview = false; state = { ...state, selectedPlayerId: playerId, selectedAction: -1 }; renderUi(); } return; }
const sequenceButton = event.target.closest('[data-sequence]');
if (sequenceButton) { gazePick = null; const selectedSequence = Number(sequenceButton.dataset.sequence); const mode = state.mode === 'pass' || (state.mode === 'start' && selectedSequence > 0) ? 'move' : state.mode; state = { ...state, selectedSequence, selectedAction: -1, mode }; controller.reset(); playbackSession = false; resetPreview = false; renderUi(); return; }
const actionButton = event.target.closest('[data-action]');
if (actionButton) { gazePick = null; playbackSession = false; resetPreview = false; state = { ...state, selectedAction: Number(actionButton.dataset.action) }; renderUi(); return; }
const modeButton = event.target.closest('[data-mode]'); if (modeButton) { gazePick = null; const mode = modeButton.dataset.mode; if (mode === 'pass') { const ownerId = finalBallOwner(state.play.sequences[state.selectedSequence]); const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!ownerId || selectedPlayer?.id !== ownerId || selectedPlayer.team !== 'offense') return; } if (mode === 'screen') { const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!screener || screener.team !== 'offense' || finalBallOwner(state.play.sequences[state.selectedSequence]) === screener.id) { document.querySelector('#status').textContent = '먼저 공을 가지지 않은 공격 선수를 선택하세요'; return; } } playbackSession = false; resetPreview = false; state = { ...state, mode, selectedSequence: mode === 'start' ? 0 : state.selectedSequence, selectedAction: -1 }; document.querySelector('#status').textContent = mode === 'start' ? '시작 위치 설정 단계 · 행동은 생성되지 않습니다' : mode === 'move' ? '이동 행동 편집 단계' : mode === 'pass' ? '패스 대상 선택 단계 · 현재 공 소유자에게서 패스 대상을 고르세요' : mode === 'screen' ? '스크린 수비 대상 선택 단계' : 'LookAt 편집 단계'; renderUi(); return; }
if (event.target.closest('#add-sequence')) { if (state.mode === 'start') { document.querySelector('#status').textContent = '먼저 이동를 눌러 시작 위치를 확정하세요'; return; } const next = addPlaySequence(state); if (next.play === state.play) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '새 단계가 이전 마지막 위치를 상속했습니다'); return; }
if (event.target.closest('#delete-sequence')) { const next = deletePlaySequence(state); if (next.play !== state.play) setState(next, '단계 삭제'); return; }
if (event.target.closest('#delete-action')) { const play = removeAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction); if (play === state.play && hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 선택해 삭제하세요'; return; } updatePlay(play, '행동 삭제'); return; }
if (event.target.closest('#play')) { if (state.mode === 'start') { document.querySelector('#status').textContent = '먼저 이동를 눌러 시작 위치를 확정하세요'; return; } gazePick = null; panel = 'court'; renderUi(); resetPreview = false; controller.setRate(document.querySelector('#playback-rate').value); controller.setLoop(document.querySelector('#loop').getAttribute('aria-pressed') === 'true'); controller.play(); playbackSession = controller.isPlaying(); state = { ...state, playing: controller.isPlaying() }; document.querySelector('#status').textContent = controller.isPlaying() ? '재생 중' : '재생할 이동이 없습니다'; return; }
if (event.target.closest('#pause')) { controller.pause(); state = { ...state, playing: false }; document.querySelector('#status').textContent = '일시정지'; return; }
if (event.target.closest('#reset')) { controller.reset(); playbackSession = false; resetPreview = true; state = { ...state, playing: false, selectedSequence: 0, selectedAction: -1 }; document.querySelector('#status').textContent = '처음 위치로 복귀'; return; }
if (event.target.closest('#tactical')) { state = { ...state, view: 'tactical' }; renderUi(); return; }
if (event.target.closest('#pov')) { panel = 'court'; gazePick = null; state = { ...state, view: 'pov' }; renderUi(); return; }
if (event.target.closest('#new-play')) { if (currentTeam) openTeamLibrary(currentTeam, '새 전술 이름과 수비 형태를 선택하세요.'); return; }
});
app.addEventListener('change', (event) => { if (event.target.id === 'saved-plays') document.querySelector('#load-play').disabled = !event.target.value; });
app.addEventListener('keydown', event => {
const current = event.target.closest('#team-navigation button, #team-action-navigation button');
if (!current || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const navigation = current.closest('[role="tablist"]'); const buttons = [...navigation.querySelectorAll('button:not([hidden]):not(:disabled)')]; const index = buttons.indexOf(current); const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length; const next = buttons[nextIndex];
next.focus(); if (navigation.id === 'team-navigation') setTeamHubView(next.id === 'show-team-search' ? 'search' : next.id === 'show-team-create' ? 'create' : 'joined', { userInitiated: true }); else if (next.id === 'show-team-management') { setTeamActionView('management', { userInitiated: true }); openOwnerPanel(selectedTeam); } else if (next.id === 'show-team-approval') { setTeamActionView('approval', { userInitiated: true }); openOwnerPanel(selectedTeam, 'approval'); } else { setTeamActionView('library', { userInitiated: true }); }
});
app.addEventListener('input', (event) => { if (event.target.id === 'play-name') { operationCoordinator.beginIntent(); saveDraft(); ensureVideoFresh(); renderVideoControls(); } if (event.target.id === 'scrubber') seekTo(event.target.value); });
app.addEventListener('change', async (event) => {
if (event.target.dataset.memberRole) { event.target.closest('.pending-user')?.querySelector('[data-save-member-role]')?.focus(); document.querySelector('#owner-message').textContent = '변경한 역할을 확인하려면 역할 저장을 누르세요.'; return; }
if (event.target.id === 'video-view') { ensureVideoFresh(); renderVideoControls(); return; }
if (event.target.id === 'gaze') { const type = event.target.value; if (type === 'player' || type === 'location') { gazePick = type; state.mode = 'move'; panel = 'court'; renderUi(); document.querySelector('#status').textContent = type === 'player' ? '시선으로 따라갈 선수를 선택하세요' : '바라볼 코트 지점을 선택하세요'; } else applyGaze(type === 'auto' ? null : { type }); }
if (event.target.id === 'playback-rate') controller.setRate(event.target.value);
if (event.target.id === 'import-file') {
const file = event.target.files[0]; event.target.value = ''; if (!file) return;
const token = operationCoordinator.beginIntent(); const previousPlay = state.play;
try { if (file.size > 2_000_000) throw new Error('2MB 이하의 전술 파일을 선택하세요'); const imported = createAppStateFromPlay(JSON.parse(await file.text())); if (!operationCoordinator.isCurrent(token) || state.play !== previousPlay) return; editorHistory.clear(); document.querySelector('#play-name').value = imported.play.name; document.querySelector('#defense-type').value = imported.play.defenseType; setState(imported, '전술 파일을 가져왔습니다', false); } catch (error) { if (operationCoordinator.isCurrent(token)) document.querySelector('#status').textContent = '가져오기 실패: ' + error.message; }
}
});
function saveDraft() {
try { const key = draftKey(); if (!storage || !key) throw new Error(); const play = structuredClone(state.play); play.name = document.querySelector('#play-name').value.trim() || '새 전술'; storage.setItem(key, JSON.stringify(play)); document.querySelector('#save-status').textContent = '이 팀의 이 기기에 임시 저장됨'; }
catch { document.querySelector('#save-status').textContent = '임시 저장 불가 · 파일로 내보내세요'; }
}
function applyGaze(lookAt) {
const play = editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { lookAt });
if (play === state.play) { document.querySelector('#status').textContent = '다른 선수를 선택하거나 편집 가능한 행동을 선택하세요'; return; }
gazePick = null; updatePlay(play, '시선 저장', state.selectedAction);
}
function seekTo(time) { controller.pause(); controller.seek(time); playbackSession = true; resetPreview = false; state.mode = 'move'; state.playing = false; document.querySelector('#status').textContent = '재생 위치 미리보기'; renderUi(); }
document.addEventListener('keydown', event => {
if (document.querySelector('.shell')?.hidden) return;
if (document.querySelector('.shell')?.dataset.orientationGate === 'true' && !event.target.closest('#orientation-gate')) { event.preventDefault(); return; }
if (event.key === 'Escape' && !document.querySelector('#project-menu').hidden) { const menu = document.querySelector('#project-menu'); menu.hidden = true; delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); document.querySelector('#video-share-open').focus(); return; }
if (event.target.closest('input,select,textarea,video,button,[contenteditable="true"]')) return;
let id = null;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') id = event.shiftKey ? 'redo' : 'undo';
else if (event.code === 'Space') id = state.playing ? 'pause' : 'play';
else if (event.key === 'Delete') id = 'delete-action';
else if (event.key === 'Escape') { document.querySelector('#project-menu').hidden = true; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); gazePick = null; state.mode = 'move'; state.selectedAction = -1; panel = 'court'; renderUi(); document.querySelector('#status').textContent = '선택 취소'; }
if (id) { event.preventDefault(); document.getElementById(id).click(); }
});
document.addEventListener('pointerdown', event => {
if (!event.target.closest('#project-menu') && !event.target.closest('#toggle-menu')) {
document.querySelector('#project-menu').hidden = true;
document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false');
}
});
document.querySelector('#board').addEventListener('click', (event) => {
if (suppressClick) { suppressClick = false; return; }
if (state.view !== 'tactical' || state.playing) return;
const hit = board.pick(event, state); if (!hit) return;
if (gazePick) { if (gazePick === 'player' && hit.type === 'player') applyGaze({ type: 'player', targetId: hit.playerId }); else if (gazePick === 'location' && hit.type === 'location') applyGaze({ type: 'location', ...snapLocation(hit.location) }); return; }
if (hit.type === 'player') { if (state.mode === 'pass') addPassForTarget(hit.playerId); else if (state.mode === 'screen') addScreenForTarget(hit.playerId); else if (state.mode === 'lookAt' && state.selectedAction >= 0) { const play = structuredClone(state.play); const action = selectedTrack()?.actions[state.selectedAction]; const target = normalizeLookAt({ type: 'player', targetId: hit.playerId }, state.selectedPlayerId, play.players); if (action && target) { play.sequences[state.selectedSequence].tracks.find((track) => track.playerId === state.selectedPlayerId).actions[state.selectedAction].lookAt = target; updatePlay(play, '선수 대상 시선 저장', state.selectedAction); } } else { playbackSession = false; resetPreview = false; state = { ...state, selectedPlayerId: hit.playerId, selectedAction: -1 }; renderUi(); } return; }
if (hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; return; }
const location = snapLocation(clampLocation(hit.location));
if (state.mode === 'start') { const next = setSelectedPlayerStart(state, location); if (next.play === state.play && hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '시작 위치 변경 · 행동 0개'); }
else if (state.mode === 'move') { const play = addAction(state.play, state.selectedSequence, state.selectedPlayerId, location); if (play !== state.play) updatePlay(play, '이동 행동 추가', -1); else if (hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; }
else if (state.mode === 'screen') { document.querySelector('#status').textContent = '수비 선수를 선택하세요'; }
else if (state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; if (action) { action.lookAt = { type: 'location', ...location }; updatePlay(play, '코트 위치 시선 저장', state.selectedAction); } }
});
const boardElement = document.querySelector('#board');
boardElement.addEventListener('pointerdown', event => {
if (event.button !== 0 || state.view !== 'tactical' || state.playing || gazePick || drag || (event.pointerType !== 'mouse' && event.isPrimary === false)) return;
if (!['start', 'move'].includes(state.mode)) return;
const hit = board.pick(event, state);
if (hit?.type !== 'player') return;
const changedPlayer = hit.playerId !== state.selectedPlayerId;
const selectedAction = selectedTrack()?.actions[state.selectedAction];
if (!changedPlayer && state.mode === 'move' && state.selectedAction >= 0 && selectedAction?.type !== 'move') return;
drag = { pointerId: event.pointerId, playerId: hit.playerId, x: event.clientX, y: event.clientY, moved: false, location: null };
state = { ...state, selectedPlayerId: hit.playerId, selectedAction: changedPlayer ? -1 : state.selectedAction };
boardElement.setPointerCapture(event.pointerId);
});
boardElement.addEventListener('pointermove', event => {
if (!drag || drag.pointerId !== event.pointerId) return;
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 7 && !drag.moved) return;
const location = board.courtLocation(event); if (!location) return;
drag.moved = true; drag.location = snapLocation(location);
state = { ...state, boardPreview: { ...(state.boardPreview || {}), [drag.playerId]: drag.location } };
document.querySelector('.board-wrap').classList.add('dragging');
document.querySelector('#status').textContent = `놓으면 위치 변경 · ${drag.location.x.toFixed(1)}, ${drag.location.z.toFixed(1)}`;
});
function finishDrag(event) {
if (!drag || drag.pointerId !== event.pointerId) return;
const completed = drag; drag = null;
document.querySelector('.board-wrap').classList.remove('dragging');
if (boardElement.hasPointerCapture(event.pointerId)) boardElement.releasePointerCapture(event.pointerId);
if (!completed.moved) return;
suppressClick = true; setTimeout(() => { suppressClick = false; }, 0);
if (event.type !== 'pointerup') { cancelActiveDrag(); return; }
state = { ...state, boardPreview: undefined };
if (state.mode === 'start') setState(setSelectedPlayerStart(state, completed.location), '시작 위치 변경');
else if (state.selectedAction < 0) { const play = addAction(state.play, state.selectedSequence, completed.playerId, completed.location); if (play !== state.play) updatePlay(play, '이동 행동 추가', -1); }
else updatePlay(editAction(state.play, state.selectedSequence, completed.playerId, state.selectedAction, { location: completed.location }), '이동 위치 변경', state.selectedAction);
}
boardElement.addEventListener('pointerup', finishDrag);
boardElement.addEventListener('pointercancel', finishDrag);
boardElement.addEventListener('lostpointercapture', event => { if (drag?.pointerId === event.pointerId) finishDrag({ type: 'pointercancel', pointerId: event.pointerId }); });
window.addEventListener('blur', () => { if (drag) finishDrag({ type: 'pointercancel', pointerId: drag.pointerId }); });
function focusViewportEligible() {
const shortSide = Math.min(window.innerWidth || 0, window.innerHeight || 0);
const coarse = window.matchMedia?.('(pointer: coarse)').matches;
return window.innerWidth <= 800 || (coarse && shortSide <= 800) || (window.innerWidth <= 900 && window.innerHeight <= 550);
}
function focusIsPortrait() {
return (window.innerHeight || 0) > (window.innerWidth || 0);
}
function updateOrientationGate(session = focusSession) {
if (session !== focusSession) return;
const blocked = mobileFocusActive && focusIsPortrait() && ['pending', 'unsupported', 'rejected'].includes(focusOrientationStatus);
const gate = document.querySelector('#orientation-gate');
if (gate) {
gate.hidden = !blocked;
gate.setAttribute('aria-hidden', String(!blocked));
if (blocked && !gate.contains(document.activeElement)) gate.querySelector('#orientation-gate-exit')?.focus();
}
for (const selector of ['.topbar', '.mobile-tabs', '.workspace', '.playback-deck']) {
const element = document.querySelector(selector);
if (element) element.inert = blocked;
}
const shell = document.querySelector('.shell');
if (shell) shell.dataset.orientationGate = blocked ? 'true' : 'false';
}
function requestLandscapeLock(session) {
const orientation = window.screen?.orientation;
const token = ++focusOrientationToken;
if (!orientation || typeof orientation.lock !== 'function') { focusOrientationStatus = 'unsupported'; updateOrientationGate(session); return; }
focusOrientationStatus = 'pending'; updateOrientationGate(session);
Promise.resolve().then(() => orientation.lock('landscape')).then(() => {
if (token !== focusOrientationToken || session !== focusSession || !mobileFocusActive || !document.fullscreenElement) {
if (!mobileFocusActive || !document.fullscreenElement) { try { orientation.unlock?.(); } catch { /* best effort */ } }
return;
}
focusOrientationStatus = 'locked'; updateOrientationGate(session);
}).catch(() => {
if (token !== focusOrientationToken || session !== focusSession || !mobileFocusActive) return;
focusOrientationStatus = 'rejected'; updateOrientationGate(session);
});
}
function unlockOrientation(session) {
const orientation = window.screen?.orientation;
const token = ++focusOrientationToken;
if (!orientation || typeof orientation.unlock !== 'function') return;
Promise.resolve().then(() => orientation.unlock()).catch(() => {}).then(() => {
if (token !== focusOrientationToken || session !== focusSession || mobileFocusActive) return;
focusOrientationStatus = 'idle'; updateOrientationGate(session);
});
}
function updateFullscreenButton(button, active) {
if (!button) return;
const label = active ? '전체화면 종료' : '전체화면';
const visualLabel = button.querySelector('.fullscreen-label');
if (visualLabel) visualLabel.textContent = label;
else button.textContent = label;
button.setAttribute('aria-label', label);
button.title = label;
button.setAttribute('aria-pressed', String(active));
}
function setMobileFocus(active, options = {}) {
cancelActiveDrag();
if (active) {
if (!mobileFocusActive) focusSession += 1;
mobileFocusActive = true;
focusOrientationStatus = options.orientationStatus || 'idle';
} else {
const endingSession = focusSession;
const shouldUnlock = mobileFocusActive || focusOrientationStatus !== 'idle';
focusEntryRequested = false;
focusSession += 1;
mobileFocusActive = false;
focusPlaybackOpen = false;
focusOrientationStatus = 'idle';
if (shouldUnlock) unlockOrientation(endingSession);
}
const shell = document.querySelector('.shell');
if (shell) {
shell.dataset.mobileFocus = mobileFocusActive ? 'true' : 'false';
shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed';
}
board.setMobileFocus?.(mobileFocusActive);
const button = document.querySelector('#toggle-fullscreen');
updateFullscreenButton(button, mobileFocusActive || Boolean(document.fullscreenElement));
document.querySelector('#toggle-focus-playback')?.setAttribute('aria-expanded', String(focusPlaybackOpen));
updateOrientationGate();
}
document.addEventListener('click', async event => {
if (event.target.closest('#orientation-gate-exit')) {
++fullscreenFocusRequest;
if (document.fullscreenElement) { try { await document.exitFullscreen?.(); } catch { /* best effort */ } }
else setMobileFocus(false);
return;
}
if (!event.target.closest('#toggle-fullscreen')) return;
const status = document.querySelector('#fullscreen-status');
if (document.fullscreenElement) { ++fullscreenFocusRequest; try { await document.exitFullscreen?.(); } catch { /* best effort */ } return; }
if (mobileFocusActive) { ++fullscreenFocusRequest; setMobileFocus(false); if (status) status.textContent = ''; return; }
const wantsFocus = focusViewportEligible();
const request = ++fullscreenFocusRequest;
focusEntryRequested = wantsFocus;
try {
if (document.documentElement.requestFullscreen) {
await document.documentElement.requestFullscreen();
if (request !== fullscreenFocusRequest) return;
if (wantsFocus && !mobileFocusActive) setMobileFocus(true);
if (wantsFocus && focusOrientationStatus === 'idle') requestLandscapeLock(focusSession);
focusEntryRequested = false;
} else if (wantsFocus) {
setMobileFocus(true, { orientationStatus: 'unsupported' });
if (status) status.textContent = '전체화면을 지원하지 않아 가로 회전 후 편집할 수 있습니다';
} else if (status) status.textContent = '전체화면을 지원하지 않아 현재 화면에 맞춰 표시합니다';
} catch {
if (request !== fullscreenFocusRequest) return;
focusEntryRequested = false;
if (wantsFocus) setMobileFocus(true, { orientationStatus: 'rejected' });
if (status) status.textContent = wantsFocus ? '브라우저 전체화면을 사용할 수 없어 가로 회전 후 편집할 수 있습니다' : '전체화면을 사용할 수 없습니다';
}
});
document.addEventListener('fullscreenchange', () => {
const status = document.querySelector('#fullscreen-status');
const button = document.querySelector('#toggle-fullscreen');
if (!document.fullscreenElement) { focusEntryRequested = false; setMobileFocus(false); }
else if (focusEntryRequested && !mobileFocusActive && focusViewportEligible()) { setMobileFocus(true); requestLandscapeLock(focusSession); focusEntryRequested = false; }
updateFullscreenButton(button, Boolean(document.fullscreenElement) || mobileFocusActive);
if (status && !document.fullscreenElement && !mobileFocusActive) status.textContent = '';
board.resize();
syncAppViewportHeight();
});
window.addEventListener('resize', () => { syncAppViewportHeight(); cancelActiveDrag(); updateOrientationGate(); board.resize(); });
window.addEventListener('orientationchange', () => { syncAppViewportHeight(); cancelActiveDrag(); updateOrientationGate(); board.resize(); });
window.visualViewport?.addEventListener('resize', syncAppViewportHeight, { passive: true });
window.visualViewport?.addEventListener('scroll', syncAppViewportHeight, { passive: true });
renderUi();
syncAppViewportHeight();
initializeAccount();
window.addEventListener('popstate', () => { const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; if (currentUser?.isOperator && window.location.pathname === '/admin') openAdmin(generation, false); else if (currentUser && window.location.pathname !== '/admin') openTeamHub('', generation); else if (window.location.pathname === '/admin') { showGate('auth', '로그인 후 운영자 페이지를 이용할 수 있습니다.'); history.replaceState({ view: 'auth' }, '', '/'); } });
function frame(now) { const delta = Math.min(0.25, (now - lastFrame) / 1000); lastFrame = now; if (videoExportActive) { requestAnimationFrame(frame); return; } const sample = selectRenderSample(state.play, { playbackSession, resetPreview: resetPreview || state.mode === 'start', selectedSequence: state.selectedSequence, selectedPlayerId: state.selectedPlayerId, selectedAction: state.selectedAction }, controller, delta); state.playing = sample.playing; if (playbackSession && !sample.playing && sample.elapsed >= sample.totalDuration && document.querySelector('#status').textContent === '재생 중') document.querySelector('#status').textContent = '재생 완료'; document.querySelector('#clock').textContent = `${sample.elapsed.toFixed(1)}s`; document.querySelector('#scrubber').value = playbackSession ? sample.elapsed : 0; document.querySelector('#time-display').textContent = (playbackSession ? sample.elapsed : 0).toFixed(1) + ' / ' + totalDuration(state.play).toFixed(1) + '초'; board.render(state.play, state, sample, delta); requestAnimationFrame(frame); }
requestAnimationFrame(frame);