feat: streamline search results and open captured analyses
Publish GitHub Pages output / publish (push) Successful in 54s

This commit is contained in:
2026-09-09 19:15:25 +09:00
parent 28035110f9
commit d26b9ca884
8 changed files with 365 additions and 53 deletions
+15 -1
View File
@@ -1,9 +1,23 @@
--- ---
updatedAt: 2026-09-09T17:59:00+09:00 updatedAt: 2026-09-09T19:13:48+09:00
--- ---
## 2026년 9월 9일 ## 2026년 9월 9일
### 조합 탐색 결과 출력 수 선택
`NEW` 결과 출력 수를 10·20·30·50개 중 선택할 수 있습니다. 기본값은 10개이며, 변경한 설정은 다음 탐색부터 적용됩니다. 출력 수를 늘리면 달성 확률 계산 시간이 늘어납니다.
### 리더 레이팅 결과를 배치 분석으로 연결
`IMPROVED` 리더 레이팅 결과의 ‘배치 분석에서 보기’를 누르면 새 탭의 분석 결과 영역으로 바로 이동합니다. 기존 레이팅 목록을 유지하면서 계산 당시 조건과 점수·달성 확률을 그대로 확인할 수 있습니다.
### 조합 탐색 간소화와 배치 분석 연결
`IMPROVED` 조합 탐색 결과를 편성·점수·달성 확률 중심으로 간소화했습니다. 각 결과의 ‘배치 분석에서 보기’를 누르면 기존 탐색을 유지한 채 새 탭에서 상세 그래프와 액티브 미발동 비교를 확인할 수 있습니다. 새 탭은 분석 결과 영역으로 바로 이동하고 포커스를 맞춥니다. 탐색 당시 조건과 계산 결과를 함께 전달하며, 이후 성장 데이터가 바뀌었다면 이전 조건의 결과임을 표시합니다.
카드 버전은 이름 아래 태그로 표시하며, 넓은 화면에서는 편성·추가 개화·점수·달성 확률·이동 버튼을 한 줄에 배치합니다. 종합력은 배치 분석에서 확인할 수 있습니다.
### 배치 분석·조합 탐색 결과 유지 및 변경 안내 ### 배치 분석·조합 탐색 결과 유지 및 변경 안내
`IMPROVED` 입력 조건이나 사용자 데이터가 바뀌어도 배치 분석 결과를 지우지 않고 이전 조건의 결과임을 표시합니다. 기존 점수·확률·그래프는 새 분석이 완료될 때까지 유지하며, 결과에 분석 당시 악곡·난이도·종합력을 함께 표시합니다. `IMPROVED` 입력 조건이나 사용자 데이터가 바뀌어도 배치 분석 결과를 지우지 않고 이전 조건의 결과임을 표시합니다. 기존 점수·확률·그래프는 새 분석이 완료될 때까지 유지하며, 결과에 분석 당시 악곡·난이도·종합력을 함께 표시합니다.
+27 -8
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { difficultyLabel } from '$lib/data/difficulty.ts'; import { difficultyLabel } from '$lib/data/difficulty.ts';
import { ANALYSIS_TRANSFER_KEY, type AnalysisTransfer } from '$lib/models/analysis-transfer.ts';
import type { AnalysisCapture } from '$lib/models/analysis-history.ts';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import type catalog from '$lib/data/exact-chart-catalog.json'; import type catalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts'; import { loadExactSongChart } from '$lib/data/chart-loader.ts';
@@ -9,9 +11,9 @@
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
type Chart = (typeof catalog.charts)[number]; type Chart = (typeof catalog.charts)[number];
type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number }; type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number; capture: AnalysisCapture };
type Evaluation = { result: ExactOptimizationResult; achievementProbabilities: ScoreAchievementProbabilities }; type Evaluation = { result: ExactOptimizationResult; achievementProbabilities: ScoreAchievementProbabilities };
type Row = Chart & Evaluation & { overallPower: number; theoreticalMaxScore: number; expectedScore: number }; type Row = Chart & Evaluation & { transfer: AnalysisTransfer; overallPower: number; theoreticalMaxScore: number; expectedScore: number };
export let charts: Chart[]; export let charts: Chart[];
export let leaderId: string; export let leaderId: string;
export let leaderName: string; export let leaderName: string;
@@ -19,7 +21,6 @@
export let inputKey: string; export let inputKey: string;
export let disabled = false; export let disabled = false;
export let getContext: (chart: Chart) => Context; export let getContext: (chart: Chart) => Context;
export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void;
const difficulty = 'all'; const difficulty = 'all';
let running = false; let running = false;
@@ -30,6 +31,7 @@
let reportedCores = 1; let reportedCores = 1;
let activeJobs: Record<number, { title: string; fraction: number }> = {}; let activeJobs: Record<number, { title: string; fraction: number }> = {};
let message = ''; let message = '';
let transferError = '';
let failures: string[] = []; let failures: string[] = [];
let snapshotKey = ''; let snapshotKey = '';
let snapshotLeader = ''; let snapshotLeader = '';
@@ -128,6 +130,8 @@
}); });
if (id !== runId) return; if (id !== runId) return;
rows = [...rows, { ...summary, result, achievementProbabilities, overallPower: context.overallPower, rows = [...rows, { ...summary, result, achievementProbabilities, overallPower: context.overallPower,
transfer: { schemaVersion: 1, source: 'rating', capture: context.capture, cards: context.cards, chart,
result, achievementProbabilities, directSongScoreBonusPercent: context.directSongScoreBonusPercent },
theoreticalMaxScore: result.theoreticalMaxScore, expectedScore: result.expectedScore }]; theoreticalMaxScore: result.theoreticalMaxScore, expectedScore: result.expectedScore }];
} catch (error) { } catch (error) {
if (id !== runId) return; if (id !== runId) return;
@@ -159,6 +163,20 @@
} }
const format = (value: number) => Math.round(value).toLocaleString('ko-KR'); const format = (value: number) => Math.round(value).toLocaleString('ko-KR');
function openInAnalysis(event: MouseEvent, transfer: AnalysisTransfer): void {
event.preventDefault();
transferError = '';
try {
sessionStorage.setItem(ANALYSIS_TRANSFER_KEY, JSON.stringify(transfer));
const opened = window.open((event.currentTarget as HTMLAnchorElement).href, '_blank');
if (!opened) throw new Error('새 탭이 차단되었습니다. 팝업을 허용한 뒤 다시 열어 주세요.');
opened.opener = null;
} catch (error) {
transferError = error instanceof Error ? error.message : '배치 분석을 열지 못했습니다.';
} finally {
try { sessionStorage.removeItem(ANALYSIS_TRANSFER_KEY); } catch { /* Storage may be blocked. */ }
}
}
function probabilityLabel(value: number): string { function probabilityLabel(value: number): string {
if (value <= 0) return '0%'; if (value <= 0) return '0%';
if (value < 0.000001) return '<0.0001%'; if (value < 0.000001) return '<0.0001%';
@@ -173,6 +191,7 @@
} }
</script> </script>
<section class="ranking" aria-labelledby="leader-ranking-title"> <section class="ranking" aria-labelledby="leader-ranking-title">
{#if transferError}<p role="alert">{transferError}</p>{/if}
<div class="heading"> <div class="heading">
<div class="section-heading"><span class="section-number">02</span><div><h2 id="leader-ranking-title">리더 레이팅 곡 추천</h2><p>지금 편성으로 도전할 곡을 찾아보세요.</p></div></div> <div class="section-heading"><span class="section-number">02</span><div><h2 id="leader-ranking-title">리더 레이팅 곡 추천</h2><p>지금 편성으로 도전할 곡을 찾아보세요.</p></div></div>
</div> </div>
@@ -242,7 +261,7 @@
<div title={`${format(Math.ceil(row.theoreticalMaxScore * .99))}점 이상 달성 확률`}><span>99% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast99Percent)}</strong></div> <div title={`${format(Math.ceil(row.theoreticalMaxScore * .99))}점 이상 달성 확률`}><span>99% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast99Percent)}</strong></div>
<div title={`${format(Math.ceil(row.theoreticalMaxScore * .95))}점 이상 달성 확률`}><span>95% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast95Percent)}</strong></div> <div title={`${format(Math.ceil(row.theoreticalMaxScore * .95))}점 이상 달성 확률`}><span>95% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast95Percent)}</strong></div>
</div></td> </div></td>
<td class="row-action"><button type="button" disabled={stale || disabled || running} on:click={() => onSelect(row, row.result, row.overallPower, row.achievementProbabilities)}>배치 보기 <span aria-hidden="true">→</span></button></td> <td class="row-action"><a href="/analysis" on:click={(event) => openInAnalysis(event, row.transfer)}>배치 분석에서 보기 <span aria-hidden="true">↗</span></a></td>
</tr>{/each}</tbody> </tr>{/each}</tbody>
</table> </table>
</div> </div>
@@ -376,8 +395,8 @@
.achievement-grid span { color: #9690a6; font-size: 10px; } .achievement-grid span { color: #9690a6; font-size: 10px; }
.achievement-grid strong { color: #cbb4db; font-size: 12px; font-weight: 650; } .achievement-grid strong { color: #cbb4db; font-size: 12px; font-weight: 650; }
.achievement-grid > div:first-child strong { color: #f1b0ce; } .achievement-grid > div:first-child strong { color: #f1b0ce; }
.row-action button { padding: 9px 11px; background: transparent; white-space: nowrap; font-size: 11px; } .row-action a { display: inline-block; padding: 9px 11px; color: inherit; background: transparent; white-space: nowrap; font-size: 11px; }
.row-action button > span { margin-left: 8px; color: #bd8ea8; } .row-action a > span { margin-left: 8px; color: #bd8ea8; }
.mobile-label { display: none; } .mobile-label { display: none; }
.list-footer { justify-content: space-between; gap: 12px; margin-top: 15px; } .list-footer { justify-content: space-between; gap: 12px; margin-top: 15px; }
.range-label { color: #c4bdcd; font-size: 11px; } .range-label { color: #c4bdcd; font-size: 11px; }
@@ -452,8 +471,8 @@
.average-score { grid-column: 2; grid-row: 3; text-align: left; font-size: 11px; } .average-score { grid-column: 2; grid-row: 3; text-align: left; font-size: 11px; }
.mobile-label { display: inline-block; min-width: 48px; margin-right: 6px; color: #828e9f; font-size: 10px; } .mobile-label { display: inline-block; min-width: 48px; margin-right: 6px; color: #828e9f; font-size: 10px; }
.row-action { grid-column: 3; grid-row: 2 / 4; align-self: end; } .row-action { grid-column: 3; grid-row: 2 / 4; align-self: end; }
.row-action button { padding: 7px 8px; font-size: 10px; } .row-action a { padding: 7px 8px; font-size: 10px; }
.row-action button > span { display: none; } .row-action a > span { display: none; }
.probability-cell { grid-column: 1 / -1; grid-row: 4; padding-top: 11px; margin-top: 5px; border-top: 1px solid #3c3342; } .probability-cell { grid-column: 1 / -1; grid-row: 4; padding-top: 11px; margin-top: 5px; border-top: 1px solid #3c3342; }
.achievement-grid { gap: 12px; text-align: center; } .achievement-grid { gap: 12px; text-align: center; }
.achievement-grid > div { gap: 5px; } .achievement-grid > div { gap: 5px; }
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { combinationAnalysisSettings, parseAnalysisTransfer, transferredAnalysisKey, type AnalysisTransfer } from '../models/analysis-transfer.ts';
import { calculateExactScore } from './score.ts';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import type { GrowthSnapshot } from '../models/analysis-history.ts';
const growth: GrowthSnapshot = { collection: { e: { owned: true, awakening: 5 } },
memberBoards: { e: ['G1'] }, boardCards: {}, memoryCount: 30, memberPowerUpPercent: 4.37 };
const state = { songId: 'song', difficulty: 'expert' as const, fixedCardIds: ['c', 'd', 'a', 'b'],
fixedAwakenings: [5, 3, 4, 4], fixedCooldownReductions: [null, 12, null, 4] as (null | 12 | 4)[],
fixedAllBlueNodes: [false, true, false, false], fixedGenerationBonusOverrides: [null, 0, 900, null],
awakening: 4, candidateGenerationBonus: 450, songScoreBonusOverride: 10,
leaderCharacterId: 'leader', leaderOutfitCardId: 'outfit' };
test('조합의 표시 순서로 바뀌어도 고정 카드 조건과 자동 후보 보드·보유 개화를 정확히 전달한다', () => {
const settings = combinationAnalysisSettings(state, ['a', 'b', 'c', 'd', 'e'], growth);
assert.deepEqual(settings.awakenings, [4, 4, 5, 3, 5]);
assert.deepEqual(settings.fixedCooldownReductions, [null, 4, null, 12, null]);
assert.deepEqual(settings.allBlueNodes, [false, false, false, true, true]);
assert.deepEqual(settings.generationBonusOverrides, [900, null, null, 0, 450]);
assert.equal(settings.leaderOutfitCardId, 'outfit');
assert.equal(settings.songScoreBonusOverride, 10);
assert.deepEqual(growth.memberBoards, { e: ['G1'] });
});
test('전달 결과는 재탐색 없이 점수·확률을 유지하고 원래 카드·채보로 시뮬레이션한다', () => {
const cards = ['a', 'b', 'c', 'd', 'e'].map((id, i) => ({ id, name: id,
active: { cooldownSeconds: 2 + i, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 55 },
special: { durationSeconds: 1, scoreUpPercent: 100 }, allowedCooldownReductions: [0] as const }));
const chart = { songId: 'song', difficulty: 'expert', durationSeconds: 8, scoreRatio: 1,
specialStartSeconds: [1, 2, 3, 4, 5], notes: Array.from({ length: 8 }, (_, timeSeconds) => ({timeSeconds, type: 'regular' as const, isFlick: false})) };
const result = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100, directSongScoreBonusPercent: 10 });
const capture = { settings: combinationAnalysisSettings(state, cards.map(c => c.id), growth), growth,
title: 'song', durationSeconds: 8, overallPower: 100, analyzedAt: '2026-09-09T09:00:00Z' };
const transfer: AnalysisTransfer = { schemaVersion: 1, capture, result, cards, chart, directSongScoreBonusPercent: 10,
achievementProbabilities: { maximum: 0.1, atLeast99Percent: 0.2, atLeast95Percent: 0.4, samples: 25000 } };
const loaded = parseAnalysisTransfer(JSON.stringify(transfer));
assert.deepEqual(loaded, transfer);
const score = calculateExactScore(loaded.result.order.map(id => loaded.cards.find(c => c.id === id)!), loaded.result.cooldownReductions, loaded.chart,
{ perfectNoteScore: loaded.result.perfectNoteScore, directSongScoreBonusPercent: loaded.directSongScoreBonusPercent });
assert.equal(score.theoreticalMaxScore, result.theoreticalMaxScore);
assert.equal(score.expectedScore, result.expectedScore);
const originalKey = transferredAnalysisKey(loaded.capture);
loaded.capture.growth.memoryCount += 1;
assert.notEqual(transferredAnalysisKey(loaded.capture), originalKey);
assert.equal(growth.memoryCount, 30);
assert.throws(() => parseAnalysisTransfer(JSON.stringify({ ...transfer, cards: [] })));
assert.throws(() => parseAnalysisTransfer(JSON.stringify({ ...transfer, chart: { ...chart, songId: 'another song' } })));
});
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createContext, runInContext } from 'node:vm';
import ts from 'typescript';
const source = readFileSync(new URL('../workers/score-optimizer.worker.ts', import.meta.url), 'utf8');
const code = ts.transpileModule(source.replace(/^import .*;\r?\n/gm, '').replace('export {};', ''), {
compilerOptions: { target: ts.ScriptTarget.ES2022 }
}).outputText;
test('조합 워커는 요청한 결과 수를 점수순으로 반환하고 미지정·잘못된 값은 10개로 처리한다', () => {
for (const [limit, expected] of [[undefined, 10], [10, 10], [20, 20], [30, 30], [50, 50], [0, 10]]) {
const messages: any[] = [];
const context = createContext({
self: { postMessage: (message: unknown) => messages.push(message) },
countOptimizationCandidates: () => 1,
optimizeExactOrderAndCooldowns: (_cards: unknown, _chart: unknown, options: { perfectNoteScore: number }) => ({
theoreticalMaxScore: options.perfectNoteScore, expectedScore: options.perfectNoteScore
})
});
runInContext(code, context);
context.request = { type: 'optimize-combinations', resultLimit: limit, chart: {},
candidates: Array.from({ length: 60 }, (_, i) => ({ cards: [], overallPower: i, perfectNoteScore: i })) };
runInContext('self.onmessage({ data: request });', context);
const result = messages.at(-1);
assert.equal(result.type, 'combination-result');
assert.equal(result.rankings.length, expected);
assert.deepEqual(Array.from(result.rankings, (row: any) => row.result.theoreticalMaxScore),
Array.from({ length: expected! }, (_, i) => 59 - i));
}
});
+63
View File
@@ -0,0 +1,63 @@
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from './analysis-history.ts';
import type { ExactOptimizationResult, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/types.ts';
import { resolveCandidateAwakening } from '../engine/combination-search.ts';
export const ANALYSIS_TRANSFER_KEY = 'holodori.analysis-transfer.v1';
export type AnalysisTransfer = {
schemaVersion: 1;
source?: 'combination' | 'rating';
capture: AnalysisCapture;
result: ExactOptimizationResult;
cards: OptimizerCard[];
chart: ExactSongChart;
directSongScoreBonusPercent: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
export type CombinationConditions = Pick<AnalysisSettings, 'songId' | 'difficulty' | 'leaderCharacterId' | 'leaderOutfitCardId' | 'songScoreBonusOverride'> & {
fixedCardIds: string[]; fixedAwakenings?: number[];
fixedCooldownReductions?: AnalysisSettings['fixedCooldownReductions'];
fixedAllBlueNodes?: boolean[]; fixedGenerationBonusOverrides?: (number | null)[];
awakening: number; candidateGenerationBonus?: number | null;
};
export function combinationAnalysisSettings(state: CombinationConditions, cardIds: readonly string[], growth: GrowthSnapshot): AnalysisSettings {
const slots = cardIds.map(id => state.fixedCardIds.indexOf(id));
return {
songId: state.songId, difficulty: state.difficulty, cardIds: [...cardIds],
awakenings: cardIds.map((id, i) => slots[i] >= 0 ? state.fixedAwakenings?.[slots[i]] ?? 0
: resolveCandidateAwakening(state.awakening, Boolean(growth.collection[id]?.owned), growth.collection[id]?.awakening ?? 5)),
fixedCooldownReductions: slots.map(slot => slot < 0 ? null : state.fixedCooldownReductions?.[slot] ?? null),
allBlueNodes: slots.map(slot => slot < 0 || state.fixedAllBlueNodes?.[slot] === true),
generationBonusOverrides: slots.map(slot => slot < 0 ? state.candidateGenerationBonus ?? null : state.fixedGenerationBonusOverrides?.[slot] ?? null),
songScoreBonusOverride: state.songScoreBonusOverride ?? null,
leaderCharacterId: state.leaderCharacterId, leaderOutfitCardId: state.leaderOutfitCardId
};
}
// Match the analysis page's key so a transferred result can flag newer growth data.
export function transferredAnalysisKey({ settings: s, growth: g }: AnalysisCapture): string {
return JSON.stringify({ songId: s.songId, difficulty: s.difficulty, input: JSON.stringify({
selectedCardIds: s.cardIds, selectedAwakenings: s.awakenings,
fixedCooldownReductions: s.fixedCooldownReductions, allBlueNodes: s.allBlueNodes,
generationBonusOverrides: s.generationBonusOverrides, songScoreBonusOverride: s.songScoreBonusOverride,
leaderCharacterId: s.leaderCharacterId, leaderOutfitCardId: s.leaderOutfitCardId,
collection: g.collection, memberBoards: g.memberBoards, boardCards: g.boardCards,
memoryCount: g.memoryCount, memberPowerUpPercent: g.memberPowerUpPercent
}) });
}
export function parseAnalysisTransfer(raw: string): AnalysisTransfer {
const value = JSON.parse(raw) as AnalysisTransfer;
const ids = value?.capture?.settings?.cardIds;
if (value?.schemaVersion !== 1 || !Array.isArray(ids) || ids.length !== 5 || new Set(ids).size !== 5 ||
!value.capture.growth || !Array.isArray(value.cards) || value.cards.length !== 5 ||
!ids.every(id => value.cards.some(card => card.id === id)) ||
!Array.isArray(value.result?.order) || value.result.order.length !== 5 || new Set(value.result.order).size !== 5 ||
!value.result.order.every(id => ids.includes(id)) || !Array.isArray(value.result.notes) ||
!Array.isArray(value.chart?.notes) || value.chart.songId !== value.capture.settings.songId ||
value.chart.difficulty !== value.capture.settings.difficulty || !Number.isFinite(value.directSongScoreBonusPercent)) {
throw new Error('전달된 분석 결과를 읽지 못했습니다. 원래 결과 목록에서 다시 열어 주세요.');
}
return value;
}
+3 -1
View File
@@ -24,6 +24,7 @@ type CombinationCandidate = {
type CombinationRequest = { type CombinationRequest = {
type: 'optimize-combinations'; type: 'optimize-combinations';
resultLimit?: number;
candidates: CombinationCandidate[]; candidates: CombinationCandidate[];
chart: ExactSongChart; chart: ExactSongChart;
}; };
@@ -145,7 +146,8 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
} }
if (!best) throw new Error('정밀 분석할 조합이 없습니다.'); if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort(compareRankings); rankings.sort(compareRankings);
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) }); const resultLimit = [10, 20, 30, 50].includes(event.data.resultLimit ?? 10) ? event.data.resultLimit ?? 10 : 10;
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, resultLimit) });
} catch (error) { } catch (error) {
self.postMessage({ self.postMessage({
type: 'error', type: 'error',
+49 -20
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte'; import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
import { analytics } from '$lib/analytics.ts'; import { analytics } from '$lib/analytics.ts';
@@ -119,6 +120,8 @@
let pendingSimulationContext: SimulationContext | null = null; let pendingSimulationContext: SimulationContext | null = null;
let resultInputKey = ''; let resultInputKey = '';
let pendingInputKey = ''; let pendingInputKey = '';
let transferredResult = false;
let transferredSource = '조합 탐색';
let simulationBase: ExactOptimizationResult | null = null; let simulationBase: ExactOptimizationResult | null = null;
let simulatedResult: ExactOptimizationResult | null = null; let simulatedResult: ExactOptimizationResult | null = null;
let excludedActiveWindows: SkillWindow[] = []; let excludedActiveWindows: SkillWindow[] = [];
@@ -334,11 +337,46 @@
analysisReady = true; 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 = createScoreOptimizerWorker();
worker.onmessage = (event) => { worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress; if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') { if (event.data.type === 'result') {
result = event.data.result; result = event.data.result;
transferredResult = false;
resultCapture = pendingCapture; resultCapture = pendingCapture;
resultInputKey = pendingInputKey; resultInputKey = pendingInputKey;
simulationContext = pendingSimulationContext; simulationContext = pendingSimulationContext;
@@ -568,24 +606,13 @@
throw new Error('서로 다른 멤버 5명과 성장 설정을 확인해 주세요.'); throw new Error('서로 다른 멤버 5명과 성장 설정을 확인해 주세요.');
} }
return { cards: context.cards, overallPower: context.unitPower.overallPower, return { cards: context.cards, overallPower: context.unitPower.overallPower,
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent }; directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent,
} capture: structuredClone({
settings: { ...presetSnapshot, songId: chart.songId, difficulty: chart.difficulty as DifficultyKey, songScoreBonusOverride: 10 },
async function showRatingResult(chart: NonNullable<typeof selectedChart>, rankingResult: ExactOptimizationResult, power: number, probabilities: ScoreAchievementProbabilities) { growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
ratingResultKey = ratingInputKey; title: chart.title, durationSeconds: chart.durationSeconds, overallPower: context.unitPower.overallPower,
selectedSongId = chart.songId; analyzedAt: new Date().toISOString()
difficulty = chart.difficulty as DifficultyKey; }) };
result = rankingResult;
achievementProbabilities = probabilities;
clearConnectionOptimizationProposal();
await tick();
resultCapture = structuredClone({
settings: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: chart.title, durationSeconds: chart.durationSeconds, overallPower: power,
analyzedAt: new Date().toISOString()
});
document.getElementById('result-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
function connectionSocketName(memberId: string, socketId: string): string { function connectionSocketName(memberId: string, socketId: string): string {
@@ -1068,12 +1095,13 @@
{#if mode === 'rating'} {#if mode === 'rating'}
<LeaderSongRanking {charts} leaderId={leaderCharacterId} leaderName={leaderMember?.name ?? ''} <LeaderSongRanking {charts} leaderId={leaderCharacterId} leaderName={leaderMember?.name ?? ''}
now={releaseNow} inputKey={ratingInputKey} disabled={!analysisReady || running || connectionOptimizationRunning || candidateCount === 0} now={releaseNow} inputKey={ratingInputKey} disabled={!analysisReady || running || connectionOptimizationRunning || candidateCount === 0}
getContext={ratingContext} onSelect={showRatingResult} /> getContext={ratingContext} />
{/if} {/if}
{#if mode === 'analysis' || result} {#if mode === 'analysis' || result}
<section class="panel result" aria-labelledby="result-title"> <section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>{mode === 'rating' ? '03' : '02'}</span><h2 id="result-title">분석 결과</h2></div></div> <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 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 === '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 mode === 'rating' && result}<p class="board-guide"><strong>{selectedChart?.title} · {difficultyLabel(difficulty)}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
@@ -1247,6 +1275,7 @@
</main> </main>
<style> <style>
#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; } .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; } 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); } .page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: var(--page-header-bottom); }
+126 -23
View File
@@ -11,6 +11,8 @@
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts'; import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts'; import { releasedSongCharts } from '$lib/data/song-release.ts';
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte'; import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
import { ANALYSIS_TRANSFER_KEY, combinationAnalysisSettings, type AnalysisTransfer } from '$lib/models/analysis-transfer.ts';
import type { GrowthSnapshot } from '$lib/models/analysis-history.ts';
import PresetManager from '$lib/components/PresetManager.svelte'; import PresetManager from '$lib/components/PresetManager.svelte';
import SongPicker from '$lib/components/SongPicker.svelte'; import SongPicker from '$lib/components/SongPicker.svelte';
import { import {
@@ -82,6 +84,7 @@
ownedCardsOnly?: boolean; ownedCardsOnly?: boolean;
maxMediumProbabilityCards?: number | null; maxMediumProbabilityCards?: number | null;
maximizeCompute?: boolean; maximizeCompute?: boolean;
resultLimit?: number;
leaderCharacterId: string; leaderCharacterId: string;
leaderOutfitCardId: string; leaderOutfitCardId: string;
}; };
@@ -141,6 +144,8 @@
let songScoreBonusOverride: number | null = 10; let songScoreBonusOverride: number | null = 10;
let ownedCardsOnly = false; let ownedCardsOnly = false;
let maxMediumProbabilityCards: number | null = null; let maxMediumProbabilityCards: number | null = null;
let resultLimit = 10;
let activeResultLimit = 10;
let maximizeCompute = false; let maximizeCompute = false;
let leaderCharacterId = leaderMembers[0]?.characterId ?? ''; let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? ''; let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
@@ -170,6 +175,43 @@
let ready = false; let ready = false;
let loadedUserData: string | null | undefined; let loadedUserData: string | null | undefined;
let userDataChanged = false; let userDataChanged = false;
let searchSnapshot: { conditions: SavedState; growth: GrowthSnapshot; title: string; durationSeconds: number; analyzedAt: string } | null = null;
let baselineTransfer: Omit<AnalysisTransfer, 'result' | 'achievementProbabilities'> | null = null;
let transferError = '';
function openInAnalysis(event: MouseEvent, ranking: DisplayRankedResult): void {
event.preventDefault();
transferError = '';
try {
let transfer: AnalysisTransfer;
if (ranking.isBaseline) {
if (!baselineTransfer) throw new Error('기준 조합 분석이 완료된 뒤 다시 열어 주세요.');
transfer = { ...baselineTransfer, result: ranking.result, achievementProbabilities: ranking.achievementProbabilities };
} else {
if (!searchSnapshot || !activeSearchChart || !ranking.cards) throw new Error('탐색이 완료된 뒤 다시 열어 주세요.');
transfer = {
schemaVersion: 1,
capture: {
settings: combinationAnalysisSettings(searchSnapshot.conditions, ranking.result.formationOrder, searchSnapshot.growth),
growth: searchSnapshot.growth, title: searchSnapshot.title, durationSeconds: searchSnapshot.durationSeconds,
analyzedAt: searchSnapshot.analyzedAt, overallPower: ranking.overallPower
},
result: ranking.result, cards: ranking.cards, chart: activeSearchChart,
directSongScoreBonusPercent: ranking.directSongScoreBonusPercent ?? 0,
achievementProbabilities: ranking.achievementProbabilities
};
}
// A same-origin new tab copies sessionStorage at creation; the search tab stays intact.
sessionStorage.setItem(ANALYSIS_TRANSFER_KEY, JSON.stringify(transfer));
const opened = window.open((event.currentTarget as HTMLAnchorElement).href, '_blank');
if (!opened) throw new Error('새 탭이 차단되었습니다. 팝업을 허용한 뒤 다시 열어 주세요.');
opened.opener = null;
} catch (error) {
transferError = error instanceof Error ? error.message : '배치 분석을 열지 못했습니다.';
} finally {
try { sessionStorage.removeItem(ANALYSIS_TRANSFER_KEY); } catch { /* Storage may be blocked. */ }
}
}
function loadUserData(): void { function loadUserData(): void {
const raw = localStorage.getItem(USER_DATA_STORAGE_KEY); const raw = localStorage.getItem(USER_DATA_STORAGE_KEY);
@@ -184,6 +226,7 @@
} }
function resetBaseline(): void { function resetBaseline(): void {
baselineTransfer = null;
baselineWorker?.terminate(); baselineWorker?.terminate();
const nextWorker = createScoreOptimizerWorker(); const nextWorker = createScoreOptimizerWorker();
baselineWorker = nextWorker; baselineWorker = nextWorker;
@@ -226,7 +269,8 @@
overallPower: baselineOverallPower, overallPower: baselineOverallPower,
achievementProbabilities: baselineAchievementProbabilities, achievementProbabilities: baselineAchievementProbabilities,
isBaseline: true isBaseline: true
} : null } : null,
activeResultLimit
); );
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline(); $: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
$: if (!result && rankedResults.length > 0) rankedResults = []; $: if (!result && rankedResults.length > 0) rankedResults = [];
@@ -262,6 +306,7 @@
songScoreBonusOverride, songScoreBonusOverride,
ownedCardsOnly, ownedCardsOnly,
maxMediumProbabilityCards, maxMediumProbabilityCards,
resultLimit,
maximizeCompute, maximizeCompute,
leaderCharacterId, leaderCharacterId,
leaderOutfitCardId leaderOutfitCardId
@@ -280,6 +325,7 @@
songScoreBonusOverride, songScoreBonusOverride,
ownedCardsOnly, ownedCardsOnly,
maxMediumProbabilityCards, maxMediumProbabilityCards,
resultLimit,
maximizeCompute, maximizeCompute,
leaderCharacterId, leaderCharacterId,
leaderOutfitCardId leaderOutfitCardId
@@ -416,6 +462,7 @@
? null ? null
: normalizeSongScoreBonusOverride(state.songScoreBonusOverride) ?? 10; : normalizeSongScoreBonusOverride(state.songScoreBonusOverride) ?? 10;
ownedCardsOnly = state.ownedCardsOnly === true; ownedCardsOnly = state.ownedCardsOnly === true;
resultLimit = [10, 20, 30, 50].includes(state.resultLimit ?? 10) ? state.resultLimit ?? 10 : 10;
maxMediumProbabilityCards = typeof state.maxMediumProbabilityCards === 'number' && maxMediumProbabilityCards = typeof state.maxMediumProbabilityCards === 'number' &&
Number.isInteger(state.maxMediumProbabilityCards) && state.maxMediumProbabilityCards >= 0 && state.maxMediumProbabilityCards < 5 Number.isInteger(state.maxMediumProbabilityCards) && state.maxMediumProbabilityCards >= 0 && state.maxMediumProbabilityCards < 5
? state.maxMediumProbabilityCards : null; ? state.maxMediumProbabilityCards : null;
@@ -902,6 +949,13 @@
}); });
const chart = await loadExactSongChart(chartMetadata.path); const chart = await loadExactSongChart(chartMetadata.path);
if (baselineWorker !== targetWorker) return; if (baselineWorker !== targetWorker) return;
baselineTransfer = structuredClone({
schemaVersion: 1,
capture: { settings: state, growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: chartMetadata.title, durationSeconds: chartMetadata.durationSeconds, overallPower: power.overallPower,
analyzedAt: new Date().toISOString() },
cards: optimizerCards, chart, directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
});
baselineOverallPower = power.overallPower; baselineOverallPower = power.overallPower;
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) }; baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
baselineWorker.postMessage({ baselineWorker.postMessage({
@@ -951,7 +1005,7 @@
bestByCombination.set(key, ranking); bestByCombination.set(key, ranking);
} }
} }
rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, 10); rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, activeResultLimit);
result = rankedResults[0]?.result ?? null; result = rankedResults[0]?.result ?? null;
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) { if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
searchPhase = 'probability'; searchPhase = 'probability';
@@ -976,7 +1030,7 @@
exactPass = 'refinement'; exactPass = 'refinement';
exactCombinationCount += refinements.length; exactCombinationCount += refinements.length;
if (maximizeCompute) runParallelExact(refinements, activeSearchChart); if (maximizeCompute) runParallelExact(refinements, activeSearchChart);
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart }); else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart, resultLimit: activeResultLimit });
return; return;
} }
} }
@@ -1028,12 +1082,12 @@
if (completedWorkers !== workerCount) return; if (completedWorkers !== workerCount) return;
completedRankings.sort(compareRankedResults); completedRankings.sort(compareRankedResults);
parallelWorkers = []; parallelWorkers = [];
completeExactPass(completedRankings.slice(0, 10)); completeExactPass(completedRankings.slice(0, activeResultLimit));
} }
if (event.data.type === 'error') fail(event.data.message); if (event.data.type === 'error') fail(event.data.message);
}; };
parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.'); parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.');
parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart }); parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart, resultLimit: activeResultLimit });
}); });
} }
@@ -1046,6 +1100,7 @@
if (previousUserData !== loadedUserData) resetBaseline(); if (previousUserData !== loadedUserData) resetBaseline();
// Rebuild reactive connection cards and candidate ownership before searching. // Rebuild reactive connection cards and candidate ownership before searching.
await tick(); await tick();
if (running) return;
} catch { } catch {
errorMessage = '사용자 데이터를 읽지 못했습니다. 사용자 데이터 페이지에서 저장 상태를 확인해 주세요.'; errorMessage = '사용자 데이터를 읽지 못했습니다. 사용자 데이터 페이지에서 저장 상태를 확인해 주세요.';
return; return;
@@ -1065,6 +1120,10 @@
return; return;
} }
running = true; running = true;
activeResultLimit = resultLimit;
searchSnapshot = structuredClone({ conditions: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: selectedChart.title, durationSeconds: selectedChart.durationSeconds, analyzedAt: new Date().toISOString() });
searchPhase = 'screening'; searchPhase = 'screening';
exactPass = 'primary'; exactPass = 'primary';
activeSearchChart = null; activeSearchChart = null;
@@ -1090,7 +1149,7 @@
activeSearchChart = chart; activeSearchChart = chart;
searchPhase = 'exact'; searchPhase = 'exact';
if (maximizeCompute) runParallelExact(finalists, chart); if (maximizeCompute) runParallelExact(finalists, chart);
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart }); else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart, resultLimit: activeResultLimit });
} catch (error) { } catch (error) {
errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.'; errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.';
running = false; running = false;
@@ -1141,7 +1200,8 @@
function buildDisplayedRankings( function buildDisplayedRankings(
rankings: readonly RankedResult[], rankings: readonly RankedResult[],
baseline: DisplayRankedResult | null baseline: DisplayRankedResult | null,
limit = 10
): DisplayRankedResult[] { ): DisplayRankedResult[] {
if (rankings.length === 0) return []; if (rankings.length === 0) return [];
const unique = new Map<string, DisplayRankedResult>(); const unique = new Map<string, DisplayRankedResult>();
@@ -1155,7 +1215,7 @@
} }
return Array.from(unique.values()) return Array.from(unique.values())
.sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline)) .sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline))
.slice(0, 10); .slice(0, limit);
} }
</script> </script>
@@ -1203,6 +1263,7 @@
</section> </section>
{/if} {/if}
<section class="panel" aria-labelledby="conditions-title"> <section class="panel" aria-labelledby="conditions-title">
<fieldset class="search-inputs" disabled={running}>
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? (eventMode ? '보유 이벤트 후보 대상' : '보유 5성 대상') : (eventMode ? '5성 + 대상 멤버 전 레어리티' : '5성 전체 대상')}</strong></div> <div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? (eventMode ? '보유 이벤트 후보 대상' : '보유 5성 대상') : (eventMode ? '5성 + 대상 멤버 전 레어리티' : '5성 전체 대상')}</strong></div>
<div class="song-fields"> <div class="song-fields">
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div> <div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
@@ -1288,6 +1349,12 @@
</div> </div>
<p class="hint">기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·가창 보너스 {songScoreBonusOverride === null ? '실제 보드값' : `${songScoreBonusOverride}%`} 조건으로 비교합니다.</p> <p class="hint">기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·가창 보너스 {songScoreBonusOverride === null ? '실제 보드값' : `${songScoreBonusOverride}%`} 조건으로 비교합니다.</p>
<PresetManager storageKey={PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} /> <PresetManager storageKey={PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
<label class="result-limit">결과 출력 수
<select bind:value={resultLimit} disabled={running} aria-describedby="result-limit-guide">
{#each [10, 20, 30, 50] as count}<option value={count}>상위 {count}개</option>{/each}
</select>
</label>
<p id="result-limit-guide" class="candidate-guide">다음 탐색부터 적용합니다. 결과 수를 늘리면 달성 확률 계산 시간이 늘어납니다. 분석한 조합이 적으면 선택한 수보다 적게 표시됩니다.</p>
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if} {#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button> <button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<label class="compute-option"> <label class="compute-option">
@@ -1297,17 +1364,21 @@
</label> </label>
<p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p> <p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p>
{#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if} {#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if}
</fieldset>
</section> </section>
<section class="panel" aria-labelledby="result-title"> <section class="panel" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div> <div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
{#if userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if} {#if userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if}
{#if !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if}
{#if transferError}<p class="error" role="alert">{transferError}</p>{/if}
{#if displayedRankings.length > 0} {#if displayedRankings.length > 0}
<div class="ranking-list"> <div class="ranking-list">
{#each displayedRankings as ranking, rank} {#each displayedRankings as ranking, rank}
<details class:baseline-ranking={ranking.isBaseline} class="ranking-card" open={rank === 0}> <svelte:element this={eventMode ? 'details' : 'article'} class:baseline-ranking={ranking.isBaseline} class="ranking-card" open={eventMode && rank === 0}>
<summary> <svelte:element this={eventMode ? 'summary' : 'div'} class="ranking-summary">
<span class="rank-number">{rank + 1}위</span> <span class="rank-number">{rank + 1}위</span>
<div class="rank-team">
<span class="rank-members"> <span class="rank-members">
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if} {#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
{#each ranking.result.formationOrder as cardId, memberIndex} {#each ranking.result.formationOrder as cardId, memberIndex}
@@ -1318,17 +1389,25 @@
· +{displayedCardAdditionalAwakenings(cardId, ranking.isBaseline)}회 · +{displayedCardAdditionalAwakenings(cardId, ranking.isBaseline)}회
{/if} {/if}
</small> </small>
<span class="member-name">{resultCardName(cardId)}</span> <span class="member-name">{cardById.get(cardId)?.name ?? cardId}</span>
{#if cardById.get(cardId)?.versionName}<small class="member-tag">{cardById.get(cardId)?.versionName}</small>{/if}
</span> </span>
{#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if} {#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if}
{/each} {/each}
</span> </span>
</div>
{#if !eventMode}
<div class="team-meta">
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
</div>
{/if}
<span class="rank-metric"> <span class="rank-metric">
<small>{eventMode ? '평균 이벤트 Pt 지수' : '최고 점수'}</small> <small>{eventMode ? '평균 이벤트 Pt 지수' : '최고 점수'}</small>
<strong>{eventMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong> <strong>{eventMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
{#if eventMode}<i>획득량 +{ranking.eventBonusPercent ?? 0}% · 평균 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')}</i>{/if} {#if eventMode}<i>획득량 +{ranking.eventBonusPercent ?? 0}% · 평균 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')}</i>{/if}
{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if} {#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b> {#if eventMode}<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
{:else}<small class="expected-score">예상 {ranking.result.expectedScore.toLocaleString('ko-KR')}</small>{/if}
</span> </span>
<span class="rank-probabilities"> <span class="rank-probabilities">
<small>최고점 대비 달성 확률</small> <small>최고점 대비 달성 확률</small>
@@ -1336,7 +1415,9 @@
<span><b>99% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast99Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.99)}점</i></span> <span><b>99% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast99Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.99)}점</i></span>
<span><b>95% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast95Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.95)}점</i></span> <span><b>95% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast95Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.95)}점</i></span>
</span> </span>
</summary> {#if !eventMode && !running && (!ranking.isBaseline || !baselineRunning)}<a class="analysis-link" href="/analysis" on:click={(event) => openInAnalysis(event, ranking)}>배치 분석에서 보기 ↗</a>{/if}
</svelte:element>
{#if eventMode}
<div class="ranking-detail"> <div class="ranking-detail">
<AnalysisResultView <AnalysisResultView
result={ranking.result} result={ranking.result}
@@ -1347,7 +1428,8 @@
showScoreSummary={false} showScoreSummary={false}
/> />
</div> </div>
</details> {/if}
</svelte:element>
{/each} {/each}
</div> </div>
<p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p> <p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p>
@@ -1360,6 +1442,8 @@
</main> </main>
<style> <style>
.result-limit { max-width: 220px; margin-top: 16px; }
.search-inputs { min-width: 0; margin: 0; padding: 0; border: 0; }
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; } main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
header { margin-bottom: var(--page-header-bottom); } header { margin-bottom: var(--page-header-bottom); }
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; } h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
@@ -1450,17 +1534,23 @@
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; } progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.ranking-list { display: grid; gap: 10px; margin-top: 18px; } .ranking-list { display: grid; gap: 10px; margin-top: 18px; }
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; } .ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
.ranking-card[open] { border-color: #55404f; background: #15151b; }
.ranking-card.baseline-ranking { border-color: #665061; background: #1b171d; } .ranking-card.baseline-ranking { border-color: #665061; background: #1b171d; }
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(150px, 12vw, 200px) clamp(280px, 23vw, 360px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; } .ranking-summary { display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(150px, 12vw, 200px) clamp(280px, 23vw, 360px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 18px; }
.ranking-card summary::-webkit-details-marker { display: none; } details.ranking-card > .ranking-summary { cursor: pointer; }
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; } article.ranking-card > .ranking-summary { grid-template-columns: 48px minmax(0, 1.5fr) max-content 130px minmax(260px, 1fr) max-content; gap: 18px; padding: 14px 18px; }
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); } article.ranking-card .rank-members { align-items: flex-start; }
.rank-team { min-width: 0; }
.team-meta { display: grid; justify-items: start; gap: 6px; color: #9ca5b3; font-size: 12px; }
.team-meta > b { white-space: nowrap; }
.team-meta b { padding: 2px 6px; border-radius: 5px; color: #f0b0c9; background: #30212a; font-size: 10px; }
.team-meta b.complete { color: #9edbb7; background: #1e3028; }
.rank-metric .expected-score { font-size: 12px; font-weight: 500; }
.rank-number { display: grid; place-items: center; width: 48px; height: 34px; border-radius: 8px; color: #171019; background: #ed8bb5; font-size: 14px; font-weight: 900; } .rank-number { display: grid; place-items: center; width: 48px; height: 34px; border-radius: 8px; color: #171019; background: #ed8bb5; font-size: 14px; font-weight: 900; }
.rank-members { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: #dce1e8; font-size: 13px; font-weight: 800; line-height: 1.55; } .rank-members { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: #dce1e8; font-size: 13px; font-weight: 800; line-height: 1.55; }
.rank-members > span { display: inline-grid; justify-items: center; gap: 1px; white-space: nowrap; } .rank-members > span { display: inline-grid; justify-items: center; gap: 1px; white-space: nowrap; }
.member-awakening { color: #8d96a5; font-size: 10px; font-weight: 750; line-height: 1.2; } .member-awakening { color: #8d96a5; font-size: 10px; font-weight: 750; line-height: 1.2; }
.member-name { line-height: 1.35; } .member-name { line-height: 1.35; }
.member-tag { margin-top: 3px; padding: 1px 6px; border: 1px solid #394150; border-radius: 5px; color: #aeb8c8; background: #202630; font-size: 10px; font-weight: 600; line-height: 1.4; }
.rank-members .added-member .member-awakening { color: #c987a4; } .rank-members .added-member .member-awakening { color: #c987a4; }
.rank-members > i { flex: 0 0 auto; color: #596270; font-style: normal; } .rank-members > i { flex: 0 0 auto; color: #596270; font-style: normal; }
.rank-members .added-member { color: #f3a5c6; } .rank-members .added-member { color: #f3a5c6; }
@@ -1482,6 +1572,9 @@
.awakening-guide { color: #aa8797; } .awakening-guide { color: #aa8797; }
.awakening-guide + .probability-guide { margin-top: 5px; } .awakening-guide + .probability-guide { margin-top: 5px; }
.ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; } .ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; }
.analysis-link { display: inline-block; margin-left: auto; padding: 5px 9px; border: 1px solid #694256; border-radius: 6px; background: #32202b; color: #f2a3c4; font-size: 12px; font-weight: 750; text-decoration: none; white-space: nowrap; }
.analysis-link:hover { background: #46303b; }
.analysis-link:focus-visible { outline: 2px solid #f2a3c4; outline-offset: 3px; }
.scores { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; } .scores { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.scores div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; } .scores div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; }
.scores span { color: #a9b1be; font-size: 14px; } .scores span { color: #a9b1be; font-size: 14px; }
@@ -1489,6 +1582,12 @@
.baseline-scores { margin-top: 10px; } .baseline-scores { margin-top: 10px; }
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; padding: 28px; border: 1px dashed #303744; border-radius: 12px; color: #858f9f; font-size: 14px; text-align: center; } .empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; padding: 28px; border: 1px dashed #303744; border-radius: 12px; color: #858f9f; font-size: 14px; text-align: center; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
article.ranking-card > .ranking-summary { grid-template-columns: 48px minmax(0, 1fr) 280px; }
article.ranking-card .rank-team { grid-column: 2 / -1; }
article.ranking-card .team-meta { grid-column: 2; grid-row: 2; }
article.ranking-card .rank-metric { grid-column: 3; grid-row: 2; }
article.ranking-card .rank-probabilities { grid-column: 2 / -1; grid-row: 3; }
.analysis-link { grid-column: 2 / -1; grid-row: 4; }
.fixed-column-headings, .fixed-column-headings,
.fixed-field { grid-template-columns: minmax(0, 1fr) 120px 304px 180px; } .fixed-field { grid-template-columns: minmax(0, 1fr) 120px 304px 180px; }
} }
@@ -1505,11 +1604,15 @@
.leader-fields, .scores { grid-template-columns: 1fr; } .leader-fields, .scores { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; } .outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.baseline-members { grid-template-columns: 1fr; } .baseline-members { grid-template-columns: 1fr; }
.ranking-card summary { grid-template-columns: 48px minmax(0, 1fr); gap: 12px 10px; padding: 13px 34px 13px 12px; } .ranking-summary, article.ranking-card > .ranking-summary { grid-template-columns: 48px minmax(0, 1fr); gap: 10px; padding: 12px; }
.rank-number { grid-column: 1; grid-row: 1; } .rank-number { grid-column: 1; grid-row: 1; }
.rank-members { grid-column: 2; grid-row: 1; } .rank-team, article.ranking-card .rank-team { grid-column: 2; grid-row: 1; }
.rank-metric:nth-of-type(3) { grid-column: 2; grid-row: 2; } .rank-metric { grid-column: 2; grid-row: 2; }
.rank-probabilities { grid-column: 1 / -1; grid-row: 3; padding: 10px 0 0; border-top: 1px solid #303744; border-left: 0; } article.ranking-card .rank-metric { grid-column: 2; grid-row: 2; }
.rank-probabilities, article.ranking-card .rank-probabilities { grid-column: 1 / -1; grid-row: 3; padding: 10px 0 0; border-top: 1px solid #303744; border-left: 0; }
article.ranking-card .team-meta { grid-column: 1 / -1; grid-row: 3; display: flex; flex-wrap: wrap; gap: 8px; font-size: 11px; }
article.ranking-card .rank-probabilities { grid-row: 4; }
.analysis-link { grid-column: 1 / -1; grid-row: 5; }
.ranking-detail { padding: 0 10px 14px; } .ranking-detail { padding: 0 10px 14px; }
} }
</style> </style>