1555 lines
98 KiB
Svelte
1555 lines
98 KiB
Svelte
<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>
|