Files
HolodoriCalc/src/routes/analysis/+page.svelte
T
dal4segno 0bdade44e3
Publish GitHub Pages output / publish (push) Successful in 32s
fix: improve mobile navigation and calculator usability
2026-09-21 11:30:22 +09:00

1555 lines
98 KiB
Svelte
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.
<script lang="ts">
import { refineBoardConnectionSongBonus } from '$lib/engine/board-connection-song-refinement';
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
import { onMount, tick } from 'svelte';
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
import { analytics } from '$lib/analytics.ts';
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts';
import { page } from '$app/stores';
import cardSummary from '$lib/data/cards-summary.json';
import SpecialNoteSummary from '$lib/components/SpecialNoteSummary.svelte';
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import PresetManager from '$lib/components/PresetManager.svelte';
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts';
import SongPicker from '$lib/components/SongPicker.svelte';
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
export let mode: 'analysis' | 'rating' = 'analysis';
type CardRow = (typeof cardSummary.cards)[number];
import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
type ConnectionOptimizationProposal = {
placements: BoardCards;
assignments: { memberId: string; socketId: string; previousCardId: string | null; cardId: string | null }[];
beforeExpected: number;
afterExpected: number;
beforeMaximum: number;
afterMaximum: number;
beforeActivationRateUp: number;
afterActivationRateUp: number;
};
type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number;
minScoreUpPercent: number;
maxScoreUpPercent: number;
maxOverlapCount: number;
};
type SavedAnalysisState = {
schemaVersion: 2;
songId: string;
difficulty: DifficultyKey;
cardIds: string[];
awakenings: number[];
fixedCooldownReductions?: (CooldownReduction | null)[];
allBlueNodes?: boolean[];
generationBonusOverrides?: (number | null)[];
songScoreBonusOverride?: number | null;
leaderCharacterId: string;
leaderOutfitCardId: string;
};
const ANALYSIS_STORAGE_KEY = mode === 'rating' ? 'holodori.rating.v1' : 'holodori.analysis.v2';
const ANALYSIS_PRESET_STORAGE_KEY = 'holodori.analysis.presets.v1';
const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const outfitCardById = new Map(cardSummary.cards.map((card) => [card.id, card]));
const allCharts = exactChartCatalog.charts;
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
let currentReleaseClock = initialReleaseClock;
let releaseNow = currentReleaseClock.now();
let charts = releasedSongCharts(allCharts, releaseNow);
let songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
const allCardById = new Map(cardSummary.cards.map((card) => [card.id, card]));
const boardMembers = boardSummary.members;
const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [
member.id,
(boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId]
?.filter((position) => position.groupId.startsWith('B'))
.map((position) => position.groupId) ?? []
]));
function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] {
const memberIds = new Set<string>();
return cards.filter((card) => {
if (memberIds.has(card.characterId)) return false;
memberIds.add(card.characterId);
return true;
}).slice(0, limit);
}
const leaderMembers = uniqueMemberCards(fiveStarCards);
let collection: CollectionState = {};
let memberBoards: MemberBoards = {};
let boardCards: BoardCards = {};
let memoryCount = 0;
let memberPowerUpPercent = 0;
let selectedSongId = songs[0]?.id ?? '';
let difficulty: DifficultyKey = 'expert';
let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id);
let selectedAwakenings = selectedCardIds.map(() => 5);
let fixedCooldownReductions: (CooldownReduction | null)[] = selectedCardIds.map(() => null);
let allBlueNodes = selectedCardIds.map(() => false);
let generationBonusOverrides: (number | null)[] = selectedCardIds.map(() => null);
let songScoreBonusOverride: number | null = null;
$: if (mode === 'rating' && songScoreBonusOverride !== 10) songScoreBonusOverride = 10;
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null;
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
type SimulationContext = { cards: OptimizerCard[]; chart: ExactSongChart; options: ExactScoreOptions };
let simulationContext: SimulationContext | null = null;
let pendingSimulationContext: SimulationContext | null = null;
let resultInputKey = '';
let pendingInputKey = '';
let transferredResult = false;
let transferredSource = '조합 탐색';
let simulationBase: ExactOptimizationResult | null = null;
let simulatedResult: ExactOptimizationResult | null = null;
let excludedActiveWindows: SkillWindow[] = [];
let toggleScoreChanges: { window: SkillWindow | null; excluded: boolean; before: number; after: number }[] = [];
$: if (result !== simulationBase) {
simulationBase = result;
toggleScoreChanges = [];
simulatedResult = null;
excludedActiveWindows = [];
}
$: displayedResult = simulatedResult ?? result;
$: maximumScoreProbabilities = result && simulationContext
? calculateMaximumScoreProbabilityTimeline(
result.order.map((id) => simulationContext!.cards.find((card) => card.id === id)!),
result.cooldownReductions, simulationContext.chart,
{ ...simulationContext.options, excludedActiveWindows }
)
: [];
$: activeImpacts = result && simulationContext && mode === 'analysis'
? calculateActiveImpacts(
result.order.map((id) => simulationContext!.cards.find((card) => card.id === id)!),
result.cooldownReductions, simulationContext.chart,
{ ...simulationContext.options, excludedActiveWindows }, result.activeWindows
)
: new Map<string, { scoreChange: number; strength: number }>();
function isExcluded(window: SkillWindow): boolean {
return excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds);
}
function toggleActiveWindow(window: SkillWindow): void {
if (!result || !simulationContext) return;
const before = (simulatedResult ?? result).theoreticalMaxScore;
const excluded = !isExcluded(window);
excludedActiveWindows = !excluded
? excludedActiveWindows.filter((excluded) => excluded.cardId !== window.cardId || excluded.startSeconds !== window.startSeconds)
: [...excludedActiveWindows, window];
if (!excludedActiveWindows.length) {
simulatedResult = null;
toggleScoreChanges = [{ window, excluded, before, after: result.theoreticalMaxScore }, ...toggleScoreChanges];
return;
}
const { cards, chart, options } = simulationContext;
const order = result.order.map((id) => cards.find((card) => card.id === id)!);
simulatedResult = {
...result,
...calculateExactScore(order, result.cooldownReductions, chart, { ...options, excludedActiveWindows }),
...buildExactSkillWindows(order, result.cooldownReductions, chart, excludedActiveWindows)
};
toggleScoreChanges = [{ window, excluded, before, after: simulatedResult.theoreticalMaxScore }, ...toggleScoreChanges];
}
function resetActiveWindows(): void {
if (result && simulatedResult) {
toggleScoreChanges = [{ window: null, excluded: false, before: simulatedResult.theoreticalMaxScore, after: result.theoreticalMaxScore }, ...toggleScoreChanges];
}
excludedActiveWindows = [];
simulatedResult = null;
}
function signedScoreChange(value: number): string {
return (value > 0 ? '+' : value < 0 ? '−' : '') + Math.abs(value).toLocaleString('ko-KR') + '점';
}
let ratingResultKey = '';
let resultCapture: AnalysisCapture | null = null;
let pendingCapture: AnalysisCapture | null = null;
const comparisonWorkers = new Set<Worker>();
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
let errorMessage = '';
let analysisReady = false;
let connectionOptimizationRunning = false;
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
let connectionOptimizationError = '';
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
let connectionOptimizationSnapshot: BoardCards | null = null;
$: charts = releasedSongCharts(allCharts, releaseNow);
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedSong = songs.find((song) => song.id === selectedSongId) ?? songs[0];
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember
? leaderOutfitCards(cardSummary.cards, leaderCharacterId)
: [];
$: leaderOutfit = outfitCardById.get(leaderOutfitCardId)?.details.costume ?? null;
$: leaderOutfitDescription = (leaderOutfit?.description ?? '')
.replace(/\[[^\]]+\]/g, '')
.replace(/\s+/g, ' ')
.trim();
$: selectedSourceCards = selectedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
$: connectionCards = cardSummary.cards
.filter((card) => card.details.connect.length > 0)
.map((card) => ({
id: card.id,
awakening: collection[card.id]?.awakening ?? 0,
connect: card.details.connect
}));
$: effectiveMemberBoards = boardsForSelectedCards(memberBoards, selectedCardIds, allBlueNodes);
$: generationBonusByCardId = Object.fromEntries(selectedCardIds
.map((cardId, index) => [cardId, generationBonusOverrides[index]] as const)
.filter((entry): entry is readonly [string, number] => entry[1] !== null));
$: boardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, selectedChart, {
placements: boardCards,
cards: connectionCards
}, {
generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
});
$: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects);
$: unitPower = (() => {
try {
return calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!,
awakening: selectedAwakenings[index]
})), leaderOutfit, boardEffects, memoryPermil, memberPowerUpPercent);
} catch {
return { overallPower: 0, baseParameterTotal: 0, potentialTotal: 0, passiveTotal: 0, outfitTotal: 0, boardTotal: 0, memoryTotal: 0, memberPowerUpTotal: 0, outfitScoreSupportPercent: 0 };
}
})();
$: overallPower = unitPower.overallPower;
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: inGamePerfectNoteScore = finalNoteScore(perfectNoteScore, boardEffects.directSongScoreBonusPercent);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: timelineDuration = resultCapture?.durationSeconds ?? selectedChart?.durationSeconds ?? 0;
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(displayedResult?.effectiveActiveWindows ?? []);
$: timelineTicks = buildTimelineTicks(timelineDuration);
$: timelineWidth = Math.max(720, timelineDuration * 6);
$: candidateCount = (() => {
fixedCooldownReductions;
try {
const sourceCards = selectedSourceCards;
if (new Set(sourceCards.map((card) => card.characterId)).size !== 5) return 0;
const cards = sourceCards.map((card, index) => optimizerCardWithBoard(card, index, sourceCards));
return cards.length === 5 ? countOptimizationCandidates(cards) : 0;
} catch {
return 0;
}
})();
$: presetSnapshot = {
schemaVersion: 2,
songId: selectedSongId,
difficulty,
cardIds: selectedCardIds,
awakenings: selectedAwakenings,
fixedCooldownReductions,
allBlueNodes,
generationBonusOverrides,
songScoreBonusOverride,
leaderCharacterId,
leaderOutfitCardId
} satisfies SavedAnalysisState;
$: ratingInputKey = JSON.stringify({
selectedCardIds, selectedAwakenings, fixedCooldownReductions, allBlueNodes,
generationBonusOverrides, songScoreBonusOverride, leaderCharacterId, leaderOutfitCardId,
collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent
});
$: if (mode === 'rating' && result && ratingResultKey !== ratingInputKey) {
result = null;
resultCapture = null;
}
$: currentAnalysisKey = JSON.stringify({ songId: selectedSongId, difficulty, input: ratingInputKey });
$: resultStale = mode === 'analysis' && Boolean(result) && resultInputKey !== currentAnalysisKey;
$: pendingStale = mode === 'analysis' && running && pendingInputKey !== currentAnalysisKey;
$: if (analysisReady) saveAnalysisState({
schemaVersion: 2,
songId: selectedSongId,
difficulty,
cardIds: selectedCardIds,
awakenings: selectedAwakenings,
fixedCooldownReductions,
allBlueNodes,
generationBonusOverrides,
songScoreBonusOverride,
leaderCharacterId,
leaderOutfitCardId
});
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
currentReleaseClock = clock;
releaseNow = currentReleaseClock.now();
});
const releaseClock = window.setInterval(() => {
releaseNow = currentReleaseClock.now();
}, 1_000);
const applyStoredUserData = (stored: string | null) => {
const parsed = stored ? JSON.parse(stored) : null;
collection = parsed?.collection && typeof parsed.collection === 'object' ? parsed.collection : {};
memberBoards = parsed?.memberBoards && typeof parsed.memberBoards === 'object' ? parsed.memberBoards : {};
boardCards = parsed?.boardCards && typeof parsed.boardCards === 'object' ? parsed.boardCards : {};
memoryCount = typeof parsed?.memoryCount === 'number' && Number.isFinite(parsed.memoryCount)
? Math.max(0, Math.min(memorySummary.maxCount, Math.round(parsed.memoryCount)))
: 0;
memberPowerUpPercent = typeof parsed?.memberPowerUpPercent === 'number' && Number.isFinite(parsed.memberPowerUpPercent)
? Math.max(0, Math.min(50, parsed.memberPowerUpPercent))
: 0;
return parsed;
};
try {
const parsed = applyStoredUserData(localStorage.getItem(USER_DATA_STORAGE_KEY));
const owned = uniqueMemberCards(fiveStarCards.filter((card) => collection[card.id]?.owned), 5);
if (owned.length === 5) {
selectedCardIds = owned.map((card) => card.id);
selectedAwakenings = owned.map((card) => collection[card.id]?.awakening ?? 0);
}
} catch {
collection = {};
memberBoards = {};
boardCards = {};
memoryCount = 0;
memberPowerUpPercent = 0;
}
try {
const currentState = localStorage.getItem(ANALYSIS_STORAGE_KEY)
?? (mode === 'rating' ? localStorage.getItem('holodori.analysis.v2') : null);
const legacyState = currentState ? null : localStorage.getItem(LEGACY_ANALYSIS_STORAGE_KEY);
restoreAnalysisState(currentState ?? legacyState, Boolean(legacyState));
if (legacyState) localStorage.removeItem(LEGACY_ANALYSIS_STORAGE_KEY);
} catch {
localStorage.removeItem(ANALYSIS_STORAGE_KEY);
} finally {
analysisReady = true;
}
if (mode === 'analysis') {
try {
const raw = sessionStorage.getItem(ANALYSIS_TRANSFER_KEY);
if (raw) {
const transfer = parseAnalysisTransfer(raw);
restoreAnalysisState(JSON.stringify({ ...transfer.capture.settings, schemaVersion: 2 }));
// Let hydration finish before mounting the result's reactive timeline.
void tick().then(async () => {
if (!mounted) return;
result = transfer.result;
resultCapture = transfer.capture;
resultInputKey = transferredAnalysisKey(transfer.capture);
achievementProbabilities = transfer.achievementProbabilities;
simulationContext = { cards: transfer.cards, chart: transfer.chart, options: {
perfectNoteScore: transfer.result.perfectNoteScore,
directSongScoreBonusPercent: transfer.directSongScoreBonusPercent
} };
transferredResult = true;
transferredSource = transfer.source === 'rating' ? '리더 레이팅' : '조합 탐색';
await tick();
requestAnimationFrame(() => {
if (!mounted) return;
const heading = document.getElementById('result-title');
heading?.focus({ preventScroll: true });
heading?.scrollIntoView({ behavior: 'instant', block: 'start' });
});
});
sessionStorage.removeItem(ANALYSIS_TRANSFER_KEY);
}
} catch (error) {
errorMessage = error instanceof Error ? error.message : '조합 탐색 결과를 읽지 못했습니다.';
}
}
worker = createScoreOptimizerWorker();
worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') {
result = event.data.result;
transferredResult = false;
resultCapture = pendingCapture;
resultInputKey = pendingInputKey;
simulationContext = pendingSimulationContext;
achievementProbabilities = event.data.achievementProbabilities;
running = false;
}
if (event.data.type === 'error') {
errorMessage = event.data.message;
running = false;
}
};
const syncStorage = (event: StorageEvent) => {
if (event.storageArea !== localStorage || (event.key !== USER_DATA_STORAGE_KEY && event.key !== null)) return;
try {
applyStoredUserData(localStorage.getItem(USER_DATA_STORAGE_KEY));
} catch {
// 다른 탭에서 쓰기가 끝나지 않은 순간에는 기존 상태를 유지한다.
}
};
window.addEventListener('storage', syncStorage);
return () => {
mounted = false;
window.clearInterval(releaseClock);
window.removeEventListener('storage', syncStorage);
worker?.terminate();
for (const comparisonWorker of comparisonWorkers) comparisonWorker.terminate();
};
});
function cardLabel(card: CardRow, awakening?: number): string {
const variant = card.versionName ? ` · ${card.versionName}` : '';
const growth = awakening === undefined ? '' : ` · ${awakening === 0 ? '명함' : `${awakening}개화`}`;
return `${card.name}${variant}${growth}`;
}
function saveAnalysisState(state: SavedAnalysisState): void {
try {
localStorage.setItem(ANALYSIS_STORAGE_KEY, JSON.stringify(state));
} catch {
// 저장소 사용이 차단된 환경에서도 분석 기능은 계속 동작한다.
}
}
function normalizeAwakening(value: unknown): number {
const awakening = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0;
return Math.max(0, Math.min(5, awakening));
}
function restoreAnalysisState(stored: string | null, legacy = false): void {
if (!stored) return;
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
if (!legacy && state.schemaVersion !== 2) return;
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (isDifficulty(state.difficulty)) difficulty = state.difficulty;
songScoreBonusOverride = normalizeSongScoreBonusOverride(state.songScoreBonusOverride);
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
const restoredCards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
if (restoredCards.length === 5 && new Set(restoredCards.map((card) => card.characterId)).size === 5) {
selectedCardIds = restoredCards.map((card) => card.id);
selectedAwakenings = restoredCards.map((card, index) => {
if (legacy && collection[card.id]?.owned) return normalizeAwakening(collection[card.id]?.awakening);
return normalizeAwakening(state.awakenings?.[index]);
});
fixedCooldownReductions = restoredCards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
allBlueNodes = restoredCards.map((_, index) => state.allBlueNodes?.[index] === true);
generationBonusOverrides = restoredCards.map((_, index) => normalizeGenerationBonusOverride(state.generationBonusOverrides?.[index]));
}
}
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
leaderCharacterId = state.leaderCharacterId;
const validOutfit = typeof state.leaderOutfitCardId === 'string'
? outfitCardById.get(state.leaderOutfitCardId)
: undefined;
leaderOutfitCardId = validOutfit?.characterId === leaderCharacterId && validOutfit.details.costume
? validOutfit.id
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
}
}
function applyPreset(event: CustomEvent<unknown>): void {
try {
restoreAnalysisState(JSON.stringify(event.detail));
errorMessage = '';
} catch {
errorMessage = '이 프리셋의 분석 조건을 불러오지 못했습니다.';
}
}
function updateAwakening(slot: number, awakening: number): void {
const normalized = normalizeAwakening(awakening);
selectedAwakenings = selectedAwakenings.map((value, index) => index === slot ? normalized : value);
}
function normalizeFixedReduction(value: unknown): CooldownReduction | null {
return value === 0 || value === 4 || value === 8 || value === 12 ? value : null;
}
function updateFixedReduction(slot: number, reduction: CooldownReduction | null): void {
fixedCooldownReductions = fixedCooldownReductions.map((value, index) => index === slot ? reduction : value);
}
function normalizeGenerationBonusOverride(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return Math.max(0, Math.min(900, Math.round(value)));
}
function sanitizeGenerationBonusInput(input: HTMLInputElement, previousValue: number | null): string {
if (input.value === '') return '';
if (!/^\d+$/.test(input.value) || Number(input.value) > 900) {
input.value = previousValue === null ? '' : String(previousValue);
return input.value;
}
input.value = String(Number(input.value));
return input.value;
}
function toggleAllBlueNodes(slot: number): void {
allBlueNodes = allBlueNodes.map((enabled, index) => index === slot ? !enabled : enabled);
}
function updateGenerationBonus(slot: number, rawValue: string): void {
const parsed = Number(rawValue);
const value = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonusOverride(parsed);
generationBonusOverrides = generationBonusOverrides.map((bonus, index) => index === slot ? value : bonus);
}
function normalizeSongScoreBonusOverride(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null;
return Math.min(10, value);
}
function updateSongScoreBonusOverride(input: HTMLInputElement): void {
const parsed = Number(input.value);
if (input.value.trim() === '' || !Number.isFinite(parsed) || parsed < 0) input.value = '';
if (parsed > 10) input.value = '10';
songScoreBonusOverride = input.value === '' ? null : Math.min(10, parsed);
}
function boardsForSelectedCards(
storedBoards: MemberBoards,
cardIds: readonly string[],
blueNodeOverrides: readonly boolean[]
): MemberBoards {
const boards = Object.fromEntries(Object.entries(storedBoards).map(([characterId, groupIds]) => [characterId, [...groupIds]]));
cardIds.forEach((cardId, index) => {
if (!blueNodeOverrides[index]) return;
const characterId = cardById.get(cardId)?.characterId;
if (!characterId) return;
const nonBlueGroups = (boards[characterId] ?? []).filter((groupId) => !groupId.startsWith('B'));
boards[characterId] = [...nonBlueGroups, ...(blueGroupsByCharacter[characterId] ?? [])];
});
return boards;
}
function updateCard(slot: number, cardId: string): void {
selectedCardIds = selectedCardIds.map((id, index) => (index === slot ? cardId : id));
if (collection[cardId]?.owned) updateAwakening(slot, normalizeAwakening(collection[cardId]?.awakening));
}
function updateLeader(characterId: string): void {
leaderCharacterId = characterId;
const outfit = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume);
leaderOutfitCardId = outfit?.id ?? '';
}
function updateOutfit(cardId: string): void {
leaderOutfitCardId = cardId;
}
function optimizerCardWithBoard(card: CardRow, index: number, unitMembers: readonly CardRow[]) {
const fixedReduction = fixedCooldownReductions[index];
const adapted = toOptimizerCard(
card,
selectedAwakenings[index] ?? 0,
fixedReduction === null || fixedReduction === undefined
? boardEffects.allowedCooldownReductionsByCardId[card.id]
: [fixedReduction],
unitMembers
);
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: {
...adapted.active,
activationRateUpPercent: activationRate
},
scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
}
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride) {
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
placements,
cards: connectionCards
}, {
generationBonusByCardId,
...(songBonusOverride === null ? {} : { directSongScoreBonusPercent: songBonusOverride })
});
const localUnitPower = calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!, awakening: selectedAwakenings[index]
})), leaderOutfit, localBoardEffects, memoryPermil, memberPowerUpPercent);
const cards = selectedSourceCards.map((card, index) => {
const fixedReduction = fixedCooldownReductions[index];
const adapted = toOptimizerCard(
card,
selectedAwakenings[index] ?? 0,
fixedReduction === null || fixedReduction === undefined
? localBoardEffects.allowedCooldownReductionsByCardId[card.id]
: [fixedReduction],
selectedSourceCards
);
return {
...adapted,
active: { ...adapted.active, activationRateUpPercent: localBoardEffects.activationRateUpByCardId[card.id] ?? 0 },
scoreSupportPercent: localUnitPower.outfitScoreSupportPercent + localBoardEffects.scoreSupportPercent
};
});
return { cards, boardEffects: localBoardEffects, unitPower: localUnitPower };
}
function ratingContext(chart: NonNullable<typeof selectedChart>) {
const context = connectionScoreContext(boardCards, chart, 10);
if (context.cards.length !== 5 || new Set(selectedSourceCards.map((card) => card.characterId)).size !== 5 ||
!Number.isFinite(context.unitPower.overallPower) || context.unitPower.overallPower <= 0) {
throw new Error('서로 다른 멤버 5명과 성장 설정을 확인해 주세요.');
}
return { cards: context.cards, overallPower: context.unitPower.overallPower,
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent,
capture: structuredClone({
settings: { ...presetSnapshot, songId: chart.songId, difficulty: chart.difficulty as DifficultyKey, songScoreBonusOverride: 10 },
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: chart.title, durationSeconds: chart.durationSeconds, overallPower: context.unitPower.overallPower,
analyzedAt: new Date().toISOString()
}) };
}
function connectionSocketName(memberId: string, socketId: string): string {
const member = boardMembers.find((item) => item.id === memberId);
const socket = member
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
: null;
if (!socket || (socket.x === 0 && socket.y === 0)) return '중앙';
if (socket.x < 0) return '왼쪽';
if (socket.x > 0) return '오른쪽';
return socket.y > 0 ? '위쪽' : '아래쪽';
}
function totalActivationRateUp(context: ReturnType<typeof connectionScoreContext>): number {
return Object.values(context.boardEffects.activationRateUpByCardId).reduce((total, value) => total + value, 0);
}
function saveBoardCardPlacements(placements: BoardCards): void {
const raw = localStorage.getItem(USER_DATA_STORAGE_KEY);
const stored = raw ? JSON.parse(raw) : {};
stored.boardCards = placements;
stored.updatedAt = new Date().toISOString();
localStorage.setItem(USER_DATA_STORAGE_KEY, JSON.stringify(stored));
boardCards = placements;
}
function clearConnectionOptimizationProposal(): void {
connectionOptimizationProposal = null;
connectionOptimizationError = '';
}
async function optimizeConnectionPlacements(): Promise<void> {
if (resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
const analysisResult = result;
const chartSummary = selectedChart;
connectionOptimizationRunning = true;
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
connectionOptimizationError = '';
connectionOptimizationProposal = null;
try {
const chart = await loadExactSongChart(chartSummary.path);
const beforeContext = connectionScoreContext(boardCards, chartSummary);
const emptyContext = connectionScoreContext({}, chartSummary);
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => analysisResult.order.map((cardId) =>
context.cards.find((card) => card.id === cardId)!
);
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
orderedCards(context), analysisResult.cooldownReductions, chart, {
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
}
).theoreticalMaxScore;
const emptyScore = referenceScore(emptyContext);
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
const candidateCards = cardSummary.cards
.filter((card) => card.details.connect.length > 0 && collection[card.id]?.owned)
.map((card) => ({ id: card.id, awakening: collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
if (candidateCards.length === 0) throw new Error('최적화에 사용할 보유 연결 카드가 없습니다.');
const placementMetricCache = new Map<string, { score: number; activationRateUp: number }>();
const placementMetrics = (card: OptimizableConnectionCard, memberId: string, socketId: string) => {
const effect = card.connect.find((entry) => entry.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
const effectKey = effect ? `${effect.effectPermilUp}:${effect.extent.map((offset) => `${offset.x},${offset.y}`).join(';')}` : card.id;
const key = `${memberId}:${socketId}:${effectKey}`;
const cached = placementMetricCache.get(key);
if (cached) return cached;
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
const metrics = {
score: referenceScore(context) - emptyScore,
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
};
placementMetricCache.set(key, metrics);
return metrics;
};
const activeConnectionSlots = boardMembers.flatMap((member) => {
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
const active = new Set(effectiveMemberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
return positions.filter((position) => active.has(position.groupId) && position.groupId.startsWith('S'))
.map((position) => ({ memberId: member.id, socketId: position.groupId }));
});
const totalPlacementChecks = candidateCards.length * activeConnectionSlots.length;
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: 0, total: totalPlacementChecks };
let completedPlacementChecks = 0;
for (const card of candidateCards) {
for (const slot of activeConnectionSlots) {
placementMetrics(card, slot.memberId, slot.socketId);
completedPlacementChecks += 1;
if (completedPlacementChecks % 8 === 0 || completedPlacementChecks === totalPlacementChecks) {
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: completedPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
}
}
connectionOptimizationProgress = { stage: '추천 배치 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
currentPlacements: boardCards,
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
preservePlacementOnTie: () => true,
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
});
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, effectiveMemberBoards, candidateCards, optimized.placements, {
currentPlacements: boardCards,
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
song: chartSummary,
songBonusEnabled: songScoreBonusOverride === null,
evaluate: (placements) => {
const context = connectionScoreContext(placements, chartSummary);
return { score: referenceScore(context), activationRateUp: totalActivationRateUp(context) };
},
onProgress: async (checks) => {
connectionOptimizationProgress = { stage: '노란 보너스 상한 보정 · ' + checks.toLocaleString('ko-KR') + '회 비교', completed: 0, total: 0 };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
});
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
});
const afterActivationRateUp = totalActivationRateUp(afterContext);
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
}
if (after.theoreticalMaxScore === analysisResult.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
throw new Error('최고점이 같고 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
}
const changedAssignments = boardMembers.flatMap((member) => {
const before = boardCards[member.id] ?? {};
const afterPlacements = optimized.placements[member.id] ?? {};
return [...new Set([...Object.keys(before), ...Object.keys(afterPlacements)])]
.filter((socketId) => before[socketId] !== afterPlacements[socketId])
.map((socketId) => ({
memberId: member.id,
socketId,
previousCardId: before[socketId] ?? null,
cardId: afterPlacements[socketId] ?? null
}));
});
connectionOptimizationProposal = {
placements: optimized.placements,
assignments: changedAssignments,
beforeExpected: analysisResult.expectedScore,
afterExpected: after.expectedScore,
beforeMaximum: analysisResult.theoreticalMaxScore,
afterMaximum: after.theoreticalMaxScore,
beforeActivationRateUp,
afterActivationRateUp
};
} catch (error) {
connectionOptimizationError = error instanceof Error ? error.message : '연결 카드 최적화를 완료하지 못했습니다.';
} finally {
connectionOptimizationRunning = false;
connectionOptimizationProgress = null;
}
}
async function applyConnectionOptimization(): Promise<void> {
if (resultStale || !connectionOptimizationProposal) return;
connectionOptimizationSnapshot = Object.fromEntries(Object.entries(boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
saveBoardCardPlacements(connectionOptimizationProposal.placements);
connectionOptimizationProposal = null;
await analyze();
}
async function undoConnectionOptimization(): Promise<void> {
if (!connectionOptimizationSnapshot) return;
saveBoardCardPlacements(connectionOptimizationSnapshot);
connectionOptimizationSnapshot = null;
connectionOptimizationProposal = null;
await analyze();
}
async function analyze(): Promise<void> {
if (running) return;
await tick();
if (running) return;
errorMessage = '';
clearConnectionOptimizationProposal();
if (!worker || !selectedSong || !selectedChart) return;
if (new Set(selectedCardIds).size !== 5) {
errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
return;
}
const selectedCards = selectedCardIds.map((id) => cardById.get(id));
if (new Set(selectedCards.map((card) => card?.characterId)).size !== 5) {
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 편성할 수 없습니다.';
return;
}
const unitMembers = selectedCards.filter((card): card is CardRow => Boolean(card));
try {
const cards = selectedCardIds.map((id, index) => {
const card = cardById.get(id);
if (!card) throw new Error('선택한 카드 정보를 찾을 수 없습니다.');
return optimizerCardWithBoard(card, index, unitMembers);
});
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
running = true;
pendingInputKey = currentAnalysisKey;
pendingCapture = structuredClone({
settings: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: selectedChart.title,
durationSeconds: selectedChart.durationSeconds,
overallPower,
analyzedAt: new Date().toISOString()
});
const noteScore = perfectNoteScore;
const songBonus = boardEffects.directSongScoreBonusPercent;
const analysisSettings = pendingCapture.settings;
analytics.trackAnalysisStart({
songId: analysisSettings.songId,
difficulty: analysisSettings.difficulty,
cards: analysisSettings.cardIds.map((cardId, index) => ({
cardId,
awakening: analysisSettings.awakenings[index]
}))
});
const chart = await loadExactSongChart(selectedChart.path);
pendingSimulationContext = { cards, chart, options: { perfectNoteScore: noteScore, directSongScoreBonusPercent: songBonus } };
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
worker.postMessage({
type: 'optimize-exact',
cards,
chart,
perfectNoteScore: noteScore,
directSongScoreBonusPercent: songBonus,
includeAchievementProbabilities: true
});
} catch (error) {
running = false;
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
}
}
function latestGrowth(): GrowthSnapshot {
const value = JSON.parse(localStorage.getItem(USER_DATA_STORAGE_KEY) ?? '{}');
return structuredClone({ collection: value.collection ?? {}, memberBoards: value.memberBoards ?? {}, boardCards: value.boardCards ?? {}, memoryCount: value.memoryCount ?? 0, memberPowerUpPercent: value.memberPowerUpPercent ?? 0 });
}
async function evaluateHistory(settings: AnalysisSettings, growth: GrowthSnapshot): Promise<{ result: ExactOptimizationResult; overallPower: number }> {
const chartSummary = charts.find((chart) => chart.songId === settings.songId && chart.difficulty === settings.difficulty);
if (!chartSummary) throw new Error('현재 공개된 악곡 데이터에서 저장한 곡을 찾을 수 없습니다.');
const sources = settings.cardIds.map((id) => {
const card = allCardById.get(id);
if (!card) throw new Error('현재 데이터에서 저장된 편성 카드를 찾을 수 없습니다.');
return card;
});
const outfit = allCardById.get(settings.leaderOutfitCardId)?.details.costume;
if (!outfit) throw new Error('저장된 리더 의상을 찾을 수 없습니다.');
const effectiveBoards = boardsForSelectedCards(growth.memberBoards, settings.cardIds, settings.allBlueNodes ?? settings.cardIds.map(() => false));
const effects = calculateBoardEffects(boardSummary, effectiveBoards, sources, settings.leaderCharacterId, chartSummary, {
placements: growth.boardCards,
cards: cardSummary.cards.filter((card) => card.details.connect.length > 0).map((card) => ({ id: card.id, awakening: growth.collection[card.id]?.awakening ?? 0, connect: card.details.connect }))
}, {
generationBonusByCardId: Object.fromEntries(settings.cardIds.flatMap((id, i) => settings.generationBonusOverrides?.[i] == null ? [] : [[id, settings.generationBonusOverrides[i]]])),
...(settings.songScoreBonusOverride == null ? {} : { directSongScoreBonusPercent: settings.songScoreBonusOverride })
});
const power = calculateUnitPower(sources.map((card, i) => ({ card, awakening: settings.awakenings[i] })), outfit, effects, memoryParameterPermil(growth.memoryCount, memorySummary.effects), growth.memberPowerUpPercent);
const cards = sources.map((card, i) => {
const fixed = settings.fixedCooldownReductions?.[i];
const adapted = toOptimizerCard(card, settings.awakenings[i], fixed == null ? effects.allowedCooldownReductionsByCardId[card.id] : [fixed], sources);
return { ...adapted, active: { ...adapted.active, activationRateUpPercent: effects.activationRateUpByCardId[card.id] ?? 0 }, scoreSupportPercent: power.outfitScoreSupportPercent + effects.scoreSupportPercent };
});
const chart = await loadExactSongChart(chartSummary.path);
return new Promise((resolve, reject) => {
const comparisonWorker = createScoreOptimizerWorker();
comparisonWorkers.add(comparisonWorker);
const finish = () => { comparisonWorker.terminate(); comparisonWorkers.delete(comparisonWorker); };
comparisonWorker.onerror = () => { finish(); reject(new Error('성장 비교 작업을 실행하지 못했습니다.')); };
comparisonWorker.onmessage = (event) => {
if (event.data.type === 'result') { finish(); resolve({ result: event.data.result, overallPower: power.overallPower }); }
if (event.data.type === 'error') { finish(); reject(new Error(event.data.message)); }
};
comparisonWorker.postMessage({ type: 'optimize-exact', cards, chart, perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartSummary.scoreRatio), directSongScoreBonusPercent: effects.directSongScoreBonusPercent });
});
}
function resultCardName(cardId: string): string {
const card = cardById.get(cardId);
return card ? cardLabel(card) : cardId;
}
function probabilityLabel(value: number | undefined): string {
if (value === undefined) return '—';
if (value <= 0) return '0%';
if (value < 0.000001) return '<0.0001%';
if (value < 0.0001) return `${(value * 100).toFixed(4)}%`;
if (value < 0.01) return `${(value * 100).toFixed(2)}%`;
return `${(value * 100).toFixed(1)}%`;
}
function scoreThreshold(maximumScore: number, ratio: number): string {
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
}
function buildTimelineTicks(durationSeconds: number): number[] {
if (durationSeconds <= 0) return [];
const interval = 10;
return Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval);
}
function timelinePosition(seconds: number, duration: number): number {
return duration > 0 ? Math.max(0, Math.min(100, seconds / duration * 100)) : 0;
}
function skillWindowStyle(startSeconds: number, endSeconds: number, duration: number): string {
return `left: ${timelinePosition(startSeconds, duration)}%; width: ${Math.max(0, timelinePosition(endSeconds, duration) - timelinePosition(startSeconds, duration))}%;`;
}
function timelineTrackStyle(width: number, duration: number): string {
return `min-width: ${width}px; --time-grid-step: ${timelinePosition(10, duration)}%;`;
}
function memberColor(cardId: string): string {
const index = result?.order.indexOf(cardId) ?? -1;
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
}
function coloredWindowStyle(window: SkillWindow, duration: number): string {
return `${skillWindowStyle(window.startSeconds, window.endSeconds, duration)} --member-color: ${memberColor(window.cardId)};`;
}
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
const merged: DisplayEffectiveWindow[] = [];
for (const window of windows) {
const previous = merged.at(-1);
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
previous.endSeconds = window.endSeconds;
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
continue;
}
merged.push({
...window,
minScoreUpPercent: window.scoreUpPercent,
maxScoreUpPercent: window.scoreUpPercent,
maxOverlapCount: window.overlapCount
});
}
return merged;
}
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
const minimum = Number(window.minScoreUpPercent.toFixed(1));
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
}
function formatSeconds(seconds: number): string {
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
}
</script>
<svelte:head><title>{mode === 'rating' ? '리더 레이팅' : '배치 분석'} · 홀로도리 계산기</title></svelte:head>
<main>
<header class="page-header">
<div>
<h1>{mode === 'rating' ? '리더 레이팅' : '배치 분석'}</h1>
<p class="page-description">{mode === 'rating' ? '입력한 편성으로 리더 가창곡의 모든 난이도를 예상 레이팅 순으로 최대 20개 추천합니다. 결과는 5개씩 확인할 수 있습니다.' : '특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.'}</p>
{#if mode === 'analysis' && selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
</div>
</header>
<section class="panel inputs" aria-labelledby="input-title">
<div class="section-title">
<div><span>01</span><h2 id="input-title">분석 조건</h2></div>
<strong>{candidateCount.toLocaleString('ko-KR')}개 후보</strong>
</div>
<div class="song-fields">
{#if mode === 'analysis'}
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} /></div>
<label>난이도
<select bind:value={difficulty}>
{#each difficulties as value}<option {value}>{difficultyLabel(value)}</option>{/each}
</select>
</label>
<label>가창 보너스 덮어쓰기 (0~10%)
<input class="number-input" type="number" min="0" max="10" step="any" value={songScoreBonusOverride ?? ''} placeholder="실제 보드값" on:input={(event) => updateSongScoreBonusOverride(event.currentTarget)}>
</label>
<div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div>
<div class="chart-meta">
<span>길이 {selectedChart?.durationSeconds ?? 0}초</span>
<span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span>
<span>기본 1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점 · {scoreCalibrationLabel}</span>
{#if boardEffects.directSongScoreBonusPercent > 0 || songScoreBonusOverride !== null}
<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%{songScoreBonusOverride === null ? '' : ' · 덮어쓰기'}</span>
{/if}
<span class="in-game-note-score">인게임 1노트 {inGamePerfectNoteScore.toLocaleString('ko-KR')}점</span>
</div>
<p class="note-score-guide">인게임 1노트는 일반 PERFECT 기준으로 가창 보너스를 반영한 점수입니다. 스킬·콤보 보너스는 제외하며, 기본점수의 소수점을 유지해 보너스를 적용한 뒤 올림합니다.</p>
{:else}
<p class="note-score-guide"><strong>가창 보너스 10% 고정</strong> · 모든 추천 대상 곡에 동일하게 적용합니다. 종합력과 스킬 효과는 각 곡의 가창 조건에 맞춰 계산합니다.</p>
{/if}
</div>
<div class="card-fields">
<div class="card-field-header" aria-hidden="true">
<span>카드 선택</span>
<span>개화</span>
<span>빈도 감소 고정</span>
<div><span>파란 노드 전체</span><span>기수 보너스<br>덮어쓰기 (0~900)</span></div>
</div>
{#each selectedCardIds as selectedId, slot}
<div class="card-field">
<label><span>{slot + 1}번 선택</span>
<select value={selectedId} on:change={(event) => updateCard(slot, event.currentTarget.value)}>
{#each fiveStarCards as card}
<option value={card.id} disabled={selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId)}>{collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
{/each}
</select>
{#if selectedId && cardById.get(selectedId)}<small class="selected-card-name">{cardLabel(cardById.get(selectedId)!)}</small>{/if}
</label>
<div class="awakening">
<span class="mobile-field-label">개화 (0~5)</span>
<div class="awakening-stepper">
<button
type="button"
disabled={selectedAwakenings[slot] <= 0}
aria-label={`${slot + 1}번 카드 개화 단계 낮추기`}
on:click={() => updateAwakening(slot, selectedAwakenings[slot] - 1)}
>‹</button>
<strong aria-live="polite">{selectedAwakenings[slot]}</strong>
<button
type="button"
disabled={selectedAwakenings[slot] >= 5}
aria-label={`${slot + 1}번 카드 개화 단계 높이기`}
on:click={() => updateAwakening(slot, selectedAwakenings[slot] + 1)}
>›</button>
</div>
</div>
<div class="cooldown-lock">
<span class="mobile-field-label">빈도 감소 고정</span>
<div class="reduction-options">
{#each REDUCTION_LOCK_OPTIONS as reduction}
<button
type="button"
class:active={fixedCooldownReductions[slot] === reduction}
aria-pressed={fixedCooldownReductions[slot] === reduction}
on:click={() => updateFixedReduction(slot, reduction)}
>{reduction === null ? '미사용' : `${reduction}%`}</button>
{/each}
</div>
</div>
<div class="board-overrides">
<span class="mobile-field-label">파란 노드 전체</span>
<span class="mobile-field-label">기수 보너스 덮어쓰기 (0~900)</span>
<label class="override-checkbox">
<input type="checkbox" checked={allBlueNodes[slot]} aria-label={`${slot + 1}번 카드 파란 노드 전체 활성화`} on:change={() => toggleAllBlueNodes(slot)}>
<span aria-hidden="true"></span>
</label>
<input class="generation-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" value={generationBonusOverrides[slot] ?? ''} placeholder="미사용" aria-label={`${slot + 1}번 카드 기수 보너스 덮어쓰기 (0~900)`} on:input={(event) => updateGenerationBonus(slot, sanitizeGenerationBonusInput(event.currentTarget, generationBonusOverrides[slot] ?? null))}>
</div>
</div>
{/each}
</div>
<p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. {mode === 'rating' ? '기수 보너스는 빈칸이면 실제 보드 수치를 사용합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.' : '기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.'}</p>
<div class="leader-fields">
<label>리더
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
{#each leaderMembers as member}<option value={member.characterId}>{member.name}</option>{/each}
</select>
</label>
<label>리더 의상
<select value={leaderOutfitCardId} on:change={(event) => updateOutfit(event.currentTarget.value)}>
{#each leaderOutfitOptions as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}
</select>
</label>
</div>
<div class="outfit-effect">
<span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
</div>
{#if mode === 'analysis'}<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>{/if}
<PresetManager storageKey={ANALYSIS_PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
{#if mode === 'analysis'}
<button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}>
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
</button>
{#if running}<progress value={progress.evaluatedCandidates} max={progress.totalCandidates}></progress>{/if}
{/if}
</section>
{#if mode === 'rating'}
<LeaderSongRanking {charts} leaderId={leaderCharacterId} leaderName={leaderMember?.name ?? ''}
now={releaseNow} inputKey={ratingInputKey} disabled={!analysisReady || running || connectionOptimizationRunning || candidateCount === 0}
getContext={ratingContext} />
{/if}
{#if mode === 'analysis' || result}
<section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>{mode === 'rating' ? '03' : '02'}</span><h2 id="result-title" tabindex="-1">분석 결과</h2></div></div>
{#if transferredResult}<p class="board-guide">{transferredSource} 당시의 계산 결과입니다. 다시 분석하면 현재 사용자 데이터를 적용합니다.</p>{/if}
{#if resultStale || pendingStale}<p class="stale-warning" role="status">입력 조건 또는 사용자 데이터가 변경되었습니다. {resultStale ? '아래는 이전 조건의 결과입니다. ' : ''}{pendingStale ? '진행 중인 분석은 시작 당시 조건으로 계속 계산합니다. ' : ''}최신 조건을 적용하려면 다시 분석해 주세요.</p>{/if}
{#if mode === 'analysis' && resultCapture}<p class="board-guide"><strong>{resultCapture.title} · {difficultyLabel(resultCapture.settings.difficulty)}</strong> · 분석 당시 종합력 {resultCapture.overallPower.toLocaleString('ko-KR')}</p>{/if}
{#if mode === 'rating' && result}<p class="board-guide"><strong>{selectedChart?.title} · {difficultyLabel(difficulty)}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
{#if result}
<div class="order-sections">
<section class="order-section">
<h3>추천 스페셜 발동 순서</h3>
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
</section>
</div>
<div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{displayedResult!.expectedScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>이론상 고점</span><strong>{displayedResult!.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div>
{#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)}
<div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
<div><span>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div>
</div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{/if}
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 빠른 배정 후 노란 보너스 상한에 따른 이동·교환을 최대 3차례 보정하며, 전역 최적해를 보장하지 않습니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</p></div>
<div class="connection-optimizer-actions">
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
</div>
</div>
<p>단, 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 해당 카드의 기존 배치를 우선 유지합니다.</p>
{#if connectionOptimizationProgress}
<div class="connection-optimizer-progress" aria-live="polite">
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>
{#if connectionOptimizationProgress.total > 0}<progress max={connectionOptimizationProgress.total} value={connectionOptimizationProgress.completed}></progress>{/if}
</div>
{/if}
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
{#if connectionOptimizationProposal}
<div class="connection-proposal" aria-live="polite">
<div class="connection-score-comparison">
<span>예상 점수 <strong>{connectionOptimizationProposal.beforeExpected.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterExpected.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterExpected >= connectionOptimizationProposal.beforeExpected ? '+' : ''}{(connectionOptimizationProposal.afterExpected - connectionOptimizationProposal.beforeExpected).toLocaleString('ko-KR')}</b></span>
<span>이론상 고점 <strong>{connectionOptimizationProposal.beforeMaximum.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterMaximum.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterMaximum >= connectionOptimizationProposal.beforeMaximum ? '+' : ''}{(connectionOptimizationProposal.afterMaximum - connectionOptimizationProposal.beforeMaximum).toLocaleString('ko-KR')}</b></span>
<span>액티브 발동률 UP 합계 <strong>{connectionOptimizationProposal.beforeActivationRateUp.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}% → {connectionOptimizationProposal.afterActivationRateUp.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}%</strong><b>{connectionOptimizationProposal.afterActivationRateUp >= connectionOptimizationProposal.beforeActivationRateUp ? '+' : ''}{(connectionOptimizationProposal.afterActivationRateUp - connectionOptimizationProposal.beforeActivationRateUp).toLocaleString('ko-KR', { maximumFractionDigits: 2 })}%p</b></span>
</div>
{#if connectionOptimizationProposal.assignments.length > 0}
<div class="connection-assignment-list">
{#each connectionOptimizationProposal.assignments as assignment (`${assignment.memberId}:${assignment.socketId}`)}
{@const member = boardMembers.find((item) => item.id === assignment.memberId)}
{@const previousCard = assignment.previousCardId ? allCardById.get(assignment.previousCardId) : null}
{@const card = assignment.cardId ? allCardById.get(assignment.cardId) : null}
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? `${previousCard.rarity}★ ${cardLabel(previousCard)}` : '비어 있음'} → {card ? `${card.rarity}★ ${cardLabel(card)}` : '배치 해제'}</strong></span>
{/each}
</div>
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
{:else}
<p class="connection-no-changes">이번 보정에서는 기존 배치보다 좋은 배치를 찾지 못했습니다.</p>
{/if}
</div>
{/if}
</section>
<section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading">
<div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
<div class="timeline-legend" aria-label="멤버 색상 범례">
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{resultCardName(cardId)}</span>{/each}
</div>
</div>
<div class="timeline-score-summary" aria-live="polite" aria-atomic="true">
<div><span>현재 설정 최고점</span><strong>{displayedResult!.theoreticalMaxScore.toLocaleString('ko-KR')}점</strong></div>
<div><span>원래 최고점</span><b>{result.theoreticalMaxScore.toLocaleString('ko-KR')}점</b></div>
<div><span>원래 대비 변화</span><b class:score-loss={displayedResult!.theoreticalMaxScore < result.theoreticalMaxScore}>{signedScoreChange(displayedResult!.theoreticalMaxScore - result.theoreticalMaxScore)}</b></div>
</div>
{#if mode === 'analysis'}
{#if toggleScoreChanges.length}
<details class="toggle-score-changes" open>
<summary>토글별 최고점 변화 · {toggleScoreChanges.length}건</summary>
<p>최근 조작부터 표시합니다. 각 변화량은 클릭 직전 설정과 비교하며, 겹친 액티브에 따라 달라집니다.</p>
<ol aria-label="토글별 최고점 변화">
{#each toggleScoreChanges as change}
<li>
<span>{#if change.window}{resultCardName(change.window.cardId)} · {formatSeconds(change.window.startSeconds)}~{formatSeconds(change.window.endSeconds)} · {change.excluded ? '미발동' : '복원'}{:else}모두 복원{/if}</span>
<strong class:score-loss={change.after < change.before} class:score-gain={change.after > change.before}>{signedScoreChange(change.after - change.before)}</strong>
<small>{change.before.toLocaleString('ko-KR')} → {change.after.toLocaleString('ko-KR')}점</small>
</li>
{/each}
</ol>
</details>
{/if}
<p class="simulation-guide" aria-live="polite">카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다. 점수에 영향을 주는 구간은 불투명하게 표시하며, 내부 색이 진하고 테두리가 굵을수록 현재 설정에서 토글할 때 최고점 변화가 큽니다. 빗금은 미발동 상태이며, 해당 구간은 복원 시 영향력을 표시합니다.
{#if excludedActiveWindows.length}
<strong>{excludedActiveWindows.length}회 미발동 적용 중</strong>
<button type="button" on:click={resetActiveWindows}>모두 복원</button>
<span>미발동 적용 중에는 달성 확률을 표시하지 않습니다. 분석 기록에는 원래 분석 결과가 저장됩니다.</span>
{/if}
</p>
{/if}
{#if mode === 'analysis'}
<details class="active-window-list">
<summary>액티브 발동 목록 · 눌러서 미발동/복원</summary>
<p>작은 그래프 대신 아래 버튼으로도 조작할 수 있습니다. 미발동으로 설정한 구간은 다시 누르면 복원됩니다.</p>
<div>
{#each result.activeWindows as window}
{@const excluded = excludedActiveWindows.some((item) => item.cardId === window.cardId && item.startSeconds === window.startSeconds)}
{@const impact = activeImpacts.get(activeWindowKey(window))}
<button type="button" disabled={!simulationContext || running} aria-pressed={excluded} on:click={() => toggleActiveWindow(window)}>
<strong>{resultCardName(window.cardId)}</strong>
<span>{formatSeconds(window.startSeconds)}~{formatSeconds(window.endSeconds)} · 효과 {window.scoreUpPercent}% · {excluded ? '미발동 (눌러서 복원)' : '발동 (눌러서 제외)'}</span>
{#if impact}<span>변경 시 최고점 {signedScoreChange(impact.scoreChange)}</span>{/if}
</button>
{/each}
</div>
</details>
{/if}
<p class="board-guide">그래프를 좌우로 밀면 전체 시간대를 확인할 수 있습니다.</p>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users must be able to scroll the timeline.) -->
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프" tabindex="0">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<ScoreTimelineChart notes={displayedResult!.notes} {maximumScoreProbabilities} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
{#each result.order as cardId, slot}
<div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
{@const impact = activeImpacts.get(activeWindowKey(window))}
<button
type="button"
class="skill-window active-window"
class:excluded={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
disabled={mode !== 'analysis' || !simulationContext || running}
style={coloredWindowStyle(window, timelineDuration) + (impact ? ` --impact-opacity: ${impact.strength > 0 ? 1 : 0.25}; --impact-color-weight: ${55 + 45 * Math.sqrt(impact.strength)}%; --impact-darken: ${20 * Math.sqrt(impact.strength)}%; --impact-border: ${impact.strength > 0 ? 1 + 2 * impact.strength : 0}px;` : '')}
title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원${impact ? ` · 토글 시 최고점 ${signedScoreChange(impact.scoreChange)}` : ''}`}
aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리${impact ? ` · 토글 시 최고점 ${signedScoreChange(impact.scoreChange)}` : ''}`}
aria-pressed={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
on:click={() => toggleActiveWindow(window)}
></button>
{/each}
</div>
</div>
{/each}
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">최종 액티브</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each displayedEffectiveActiveWindows as window}
<span
class="skill-window effective-window"
style={coloredWindowStyle(window, timelineDuration)}
title={`${resultCardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`}
role="img"
aria-label={`${resultCardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
></span>
{/each}
</div>
</div>
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">스페셜</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each result.specialWindows as window}
<span
class="skill-window special-window"
style={coloredWindowStyle(window, timelineDuration)}
title={`${resultCardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
role="img"
aria-label={`${resultCardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
></span>
{/each}
</div>
</div>
<div class="timeline-row axis-row" aria-hidden="true">
<span class="lane-label axis-label"></span>
<div class="timeline-track time-axis" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second, timelineDuration)}%;`}>{second}초</span>{/each}
</div>
</div>
</div>
</div>
</section>
<SpecialNoteSummary {result} cardName={resultCardName} />
{:else}
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
{/if}
</section>
<AnalysisHistory capture={resultCapture} {result} cardName={resultCardName} currentGrowth={latestGrowth} evaluate={evaluateHistory} />
{/if}
</main>
<style>
.mobile-field-label, .selected-card-name { display: none; }
.active-window-list { margin: 12px 0; padding: 12px; border: 1px solid #343c49; border-radius: 10px; }
.active-window-list summary { min-height: 44px; align-content: center; cursor: pointer; font-size: 14px; }
.active-window-list p { color: #adb5c2; font-size: 13px; line-height: 1.6; }
.active-window-list > div { display: grid; gap: 8px; max-height: 360px; overflow-y: auto; }
.active-window-list button { display: grid; gap: 6px; min-height: 44px; padding: 12px; border: 1px solid #414b5b; border-radius: 8px; background: #202630; color: #e8ebf1; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.active-window-list button[aria-pressed='true'] { border-color: #ef8bb6; background: #37232e; }
.active-window-list button span { font-size: 12px; line-height: 1.5; }
#result-title { scroll-margin-top: 90px; }
.stale-warning { margin: 14px 0; padding: 11px 14px; border: 1px solid #665333; border-radius: 9px; color: #edcf92; background: #292319; font-size: 13px; line-height: 1.6; }
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: var(--page-header-bottom); }
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
.page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; }
.calibration-warning { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border: 1px solid #604653; border-radius: 8px; color: #c8aeb9; background: #261d23; font-size: 13px; line-height: 1.55; }
.calibration-warning strong { margin-right: 5px; color: #f1a3c4; }
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.section-title > div { justify-content: flex-start; }
.section-title span { color: #ef8cb6; font-size: 13px; font-weight: 900; }
h2 { margin: 0; font-size: 18px; }
.section-title > strong { color: #aeb6c4; font-size: 14px; }
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select, .number-input { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus, .number-input:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 4fr) clamp(110px, 11vw, 170px) clamp(190px, 17vw, 250px) clamp(140px, 14vw, 220px); align-items: start; gap: 12px; margin-top: 22px; }
.song-picker-field { min-width: 0; }
.power-summary { display: grid; gap: 4px; min-height: 42px; margin-top: 25px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
.power-summary span { color: #929cab; font-size: 12px; font-weight: 750; }
.power-summary strong { color: #f2a3c4; font-size: 16px; }
.chart-meta { display: flex; flex-wrap: wrap; grid-column: 1 / -1; gap: 8px; min-height: 42px; align-items: center; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.chart-meta .in-game-note-score { color: #f0f4ff; font-weight: 700; }
.note-score-guide { grid-column: 1 / -1; margin: 0; color: #adb5c2; font-size: 12px; line-height: 1.6; }
.card-fields { display: grid; gap: 9px; margin-top: 18px; }
.card-field-header, .card-field { display: grid; grid-template-columns: minmax(280px, 1fr) clamp(120px, 10vw, 160px) clamp(280px, 24vw, 360px) clamp(180px, 16vw, 240px); gap: 9px; }
.card-field-header { padding: 0 2px; color: #8f98a8; font-size: 13px; font-weight: 800; }
.card-field-header span { padding-left: 2px; }
.card-field-header > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 12px; text-align: center; }
.card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
.card-field > .awakening { min-width: 0; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
.awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; }
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
.awakening-stepper button:disabled { color: #525b69; background: #171c24; cursor: default; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; white-space: nowrap; }
.cooldown-lock { min-width: 0; }
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 13px; font-weight: 750; cursor: pointer; }
.reduction-options button:first-child { border-left: 0; }
.reduction-options button:hover { color: #e9edf3; background: #202630; }
.reduction-options button.active { color: #171019; background: #ed8bb5; }
.reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; }
.board-overrides { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; align-self: end; height: 42px; }
.override-checkbox { display: grid; place-items: center; cursor: pointer; }
.override-checkbox input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.override-checkbox span { display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
.override-checkbox input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.override-checkbox input:checked + span::after { content: '✓'; color: #171019; font-size: 14px; font-weight: 950; }
.override-checkbox input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.generation-input { align-self: center; width: 100%; min-width: 0; height: 32px; padding: 0 7px; border: 1px solid #414957; border-radius: 7px; color: #e8ebf1; background: #11151b; font-size: 12px; font-weight: 800; text-align: center; }
.generation-input::placeholder { color: #68717f; opacity: 1; }
.board-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; }
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
.outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; }
.outfit-effect p { margin: 0; color: #b9c1cd; font-size: 14px; line-height: 1.55; }
.power-detail { margin: 10px 0 0; color: #858f9f; font-size: 13px; text-align: right; }
.analyze { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.analyze:hover:not(:disabled) { background: #f2a0c2; }
.analyze:disabled { opacity: .55; cursor: default; }
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; }
.order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 22px; }
.order-section { min-width: 0; }
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 13px; }
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 14px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; }
.probability-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
.probability-grid > div { display: grid; gap: 7px; padding: 16px; border: 1px solid #303744; border-radius: 10px; background: #171d25; }
.probability-grid span { color: #9ca5b3; font-size: 14px; }
.probability-grid strong { color: #e5e9ef; font-size: 20px; }
.probability-grid small { color: #8d96a5; font-size: 12px; font-weight: 700; }
.probability-guide { margin: 9px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.connection-optimizer { margin-top: 18px; padding: 18px; border: 1px solid #3b5445; border-radius: 13px; background: #151e19; }
.connection-optimizer-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.connection-optimizer-heading h3 { margin: 0; color: #dce8e0; font-size: 17px; }
.connection-optimizer-heading p { margin: 5px 0 0; color: #8fa095; font-size: 13px; }
.connection-optimizer-actions { display: flex; flex: 0 0 auto; gap: 7px; }
.connection-optimizer button { min-height: 38px; padding: 8px 12px; border: 1px solid #46574c; border-radius: 8px; color: #becbc2; background: #202a23; font-weight: 800; cursor: pointer; }
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }
.connection-optimizer button:disabled { opacity: .55; cursor: default; }
.connection-optimizer-progress { display: grid; gap: 7px; margin-top: 14px; padding: 10px 12px; border: 1px solid #314238; border-radius: 9px; background: #111a15; }
.connection-optimizer-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.connection-optimizer-progress strong { color: #b9dac5; font-size: 13px; }
.connection-optimizer-progress span { color: #86a391; font-size: 12px; font-weight: 800; }
.connection-optimizer-progress progress { width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: 999px; color: #78c493; background: #27322c; appearance: none; }
.connection-optimizer-progress progress::-webkit-progress-bar { border-radius: 999px; background: #27322c; }
.connection-optimizer-progress progress::-webkit-progress-value { border-radius: 999px; background: #78c493; }
.connection-optimizer-progress progress::-moz-progress-bar { border-radius: 999px; background: #78c493; }
.connection-optimizer-error { margin: 12px 0 0; color: #e3a0a0; font-size: 13px; }
.connection-proposal { display: grid; gap: 12px; margin-top: 14px; padding-top: 14px; border-top: 1px solid rgb(120 196 147 / 18%); }
.connection-score-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.connection-score-comparison span { display: grid; gap: 5px; padding: 10px 12px; border-radius: 8px; color: #91a59a; background: #111813; font-size: 12px; }
.connection-score-comparison strong { color: #e0ebe4; font-size: 14px; }
.connection-score-comparison b { color: #82d09e; font-size: 13px; }
.connection-assignment-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 6px; max-height: 240px; overflow-y: auto; }
.connection-assignment-list > span { display: grid; gap: 3px; padding: 8px 10px; border: 1px solid #304038; border-radius: 7px; background: #121915; }
.connection-assignment-list small { color: #819287; font-size: 11px; }
.connection-assignment-list strong { color: #dbe7df; font-size: 12px; overflow-wrap: anywhere; }
.connection-no-changes { margin: 0; padding: 10px 12px; border-radius: 8px; color: #a9cdb6; background: #18271d; font-size: 13px; font-weight: 750; }
.connection-proposal .apply-connection { justify-self: end; }
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
.timeline-heading h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 13px; }
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 12px; }
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
.timeline-canvas { min-width: 100%; padding: 12px 0 8px; }
.timeline-row { display: flex; min-height: 36px; }
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 11px; }
.timeline-track { position: relative; flex: 1 0 auto; }
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
.skill-track { height: 36px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 8px; height: 20px; opacity: var(--impact-opacity, .72); --impact-fill: color-mix(in srgb, color-mix(in srgb, var(--member-color) var(--impact-color-weight, 100%), white), black var(--impact-darken, 0%)); background: var(--impact-fill); padding: 0; border: 0; box-shadow: inset 0 0 0 var(--impact-border, 0px) rgb(255 255 255 / 85%); cursor: pointer; }
.active-window:hover:not(:disabled), .active-window:focus-visible { opacity: 1; outline: 2px solid #fff; outline-offset: 2px; }
.active-window.excluded { opacity: var(--impact-opacity, .28); border: 1px dashed var(--member-color); background: repeating-linear-gradient(135deg, transparent 0 3px, rgb(255 255 255 / 55%) 3px 4px), var(--impact-fill); }
.active-window:disabled { cursor: default; }
.timeline-score-summary { display: flex; flex-wrap: wrap; gap: 16px 32px; padding: 16px; margin-bottom: 12px; border: 1px solid #454052; border-radius: 10px; background: #22202a; }
.timeline-score-summary > div { display: grid; gap: 6px; }
.timeline-score-summary span { color: #adb5c2; font-size: 12px; }
.timeline-score-summary strong { color: #f2a3c4; font-size: 22px; }
.timeline-score-summary b { color: #e1e5ec; font-size: 16px; }
.toggle-score-changes { margin: 12px 0; padding: 12px 16px; border: 1px solid #343c49; border-radius: 10px; }
.toggle-score-changes summary { cursor: pointer; color: #e1e5ec; font-size: 14px; }
.toggle-score-changes p { color: #adb5c2; font-size: 12px; line-height: 1.6; }
.toggle-score-changes ol { list-style: none; max-height: 180px; overflow-y: auto; margin: 0; padding: 0; }
.toggle-score-changes li { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: baseline; padding: 8px 0; border-top: 1px solid #343c49; font-size: 13px; }
.toggle-score-changes li > span { flex: 1 1 240px; color: #e1e5ec; }
.toggle-score-changes small { color: #adb5c2; }
.timeline-score-summary .score-loss, .toggle-score-changes .score-loss { color: #f2a3a3; }
.toggle-score-changes .score-gain { color: #82d09e; }
.simulation-guide { color: #adb5c2; font-size: 13px; line-height: 1.7; }
.simulation-guide strong, .simulation-guide span { display: block; }
.simulation-guide button { margin: 4px 0; }
.summary-row { background: rgb(255 255 255 / 1.5%); }
.summary-label { color: #e1e5ec; font-weight: 800; }
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
.special-window { top: 11px; height: 14px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
.axis-row { min-height: 24px; border-top: 1px solid #343c49 !important; }
.axis-label { color: #778293; font-size: 12px; font-weight: 750; }
.time-axis { height: 24px; }
.time-axis::before { display: none; }
.time-axis span { position: absolute; top: 5px; color: #747f90; font-size: 11px; transform: translateX(-50%); white-space: nowrap; }
.time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); }
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; }
@media (max-width: 1100px) {
.card-field-header, .card-field { grid-template-columns: minmax(0, 1fr) 120px 280px 180px; }
}
@media (max-width: 900px) {
.order-sections { grid-template-columns: 1fr; }
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.page-header { align-items: flex-start; }
.panel { padding: 17px; }
.song-fields { grid-template-columns: minmax(0, 1fr) 100px; }
.power-summary { grid-column: 1 / -1; margin-top: 0; }
.chart-meta { grid-column: 1 / -1; height: auto; }
.card-field { grid-template-columns: minmax(0, 1fr) 102px; }
.card-field-header { display: none; }
.card-field > label { grid-template-columns: 58px minmax(0, 1fr); }
.card-field > .awakening { grid-template-columns: 1fr; }
.awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; }
.cooldown-lock { grid-column: 1 / -1; grid-template-columns: 88px minmax(0, 1fr); }
.board-overrides { grid-column: 1 / -1; }
.leader-fields { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; }
.connection-optimizer-heading { align-items: stretch; flex-direction: column; }
.connection-optimizer-actions { display: grid; }
.connection-score-comparison { grid-template-columns: 1fr; }
.connection-proposal .apply-connection { justify-self: stretch; }
.timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; }
.timeline-canvas { padding-right: 0; }
}
@media (max-width: 900px) {
.song-fields { grid-template-columns: minmax(0, 1fr); }
.section-title { flex-wrap: wrap; }
.card-field { grid-template-columns: minmax(0, 1fr); padding: 14px 0; border-top: 1px solid #343c49; gap: 12px; }
.card-field > label { grid-template-columns: minmax(0, 1fr); gap: 7px; }
.mobile-field-label { display: block; color: #aab4c3; font-size: 13px; line-height: 1.5; margin-bottom: 6px; }
.selected-card-name { display: block; color: #c1c8d3; font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
.board-overrides { height: auto; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: end; }
.board-overrides .mobile-field-label { margin-bottom: 0; }
.override-checkbox, .generation-input { min-height: 44px; }
.awakening-stepper { height: 46px; grid-template-columns: 44px minmax(0, 1fr) 44px; }
.reduction-options { height: 46px; }
.chart-meta span { white-space: normal; }
}
</style>