feat: streamline search results and open captured analyses
Publish GitHub Pages output / publish (push) Successful in 54s
Publish GitHub Pages output / publish (push) Successful in 54s
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
<script lang="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 type catalog from '$lib/data/exact-chart-catalog.json';
|
||||
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
||||
@@ -9,9 +11,9 @@
|
||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||
|
||||
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 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 leaderId: string;
|
||||
export let leaderName: string;
|
||||
@@ -19,7 +21,6 @@
|
||||
export let inputKey: string;
|
||||
export let disabled = false;
|
||||
export let getContext: (chart: Chart) => Context;
|
||||
export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void;
|
||||
|
||||
const difficulty = 'all';
|
||||
let running = false;
|
||||
@@ -30,6 +31,7 @@
|
||||
let reportedCores = 1;
|
||||
let activeJobs: Record<number, { title: string; fraction: number }> = {};
|
||||
let message = '';
|
||||
let transferError = '';
|
||||
let failures: string[] = [];
|
||||
let snapshotKey = '';
|
||||
let snapshotLeader = '';
|
||||
@@ -128,6 +130,8 @@
|
||||
});
|
||||
if (id !== runId) return;
|
||||
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 }];
|
||||
} catch (error) {
|
||||
if (id !== runId) return;
|
||||
@@ -159,6 +163,20 @@
|
||||
}
|
||||
|
||||
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 {
|
||||
if (value <= 0) return '0%';
|
||||
if (value < 0.000001) return '<0.0001%';
|
||||
@@ -173,6 +191,7 @@
|
||||
}
|
||||
</script>
|
||||
<section class="ranking" aria-labelledby="leader-ranking-title">
|
||||
{#if transferError}<p role="alert">{transferError}</p>{/if}
|
||||
<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>
|
||||
@@ -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 * .95))}점 이상 달성 확률`}><span>95% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast95Percent)}</strong></div>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -376,8 +395,8 @@
|
||||
.achievement-grid span { color: #9690a6; font-size: 10px; }
|
||||
.achievement-grid strong { color: #cbb4db; font-size: 12px; font-weight: 650; }
|
||||
.achievement-grid > div:first-child strong { color: #f1b0ce; }
|
||||
.row-action button { padding: 9px 11px; background: transparent; white-space: nowrap; font-size: 11px; }
|
||||
.row-action button > span { margin-left: 8px; color: #bd8ea8; }
|
||||
.row-action a { display: inline-block; padding: 9px 11px; color: inherit; background: transparent; white-space: nowrap; font-size: 11px; }
|
||||
.row-action a > span { margin-left: 8px; color: #bd8ea8; }
|
||||
.mobile-label { display: none; }
|
||||
.list-footer { justify-content: space-between; gap: 12px; margin-top: 15px; }
|
||||
.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; }
|
||||
.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 button { padding: 7px 8px; font-size: 10px; }
|
||||
.row-action button > span { display: none; }
|
||||
.row-action a { padding: 7px 8px; font-size: 10px; }
|
||||
.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; }
|
||||
.achievement-grid { gap: 12px; text-align: center; }
|
||||
.achievement-grid > div { gap: 5px; }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type CombinationCandidate = {
|
||||
|
||||
type CombinationRequest = {
|
||||
type: 'optimize-combinations';
|
||||
resultLimit?: number;
|
||||
candidates: CombinationCandidate[];
|
||||
chart: ExactSongChart;
|
||||
};
|
||||
@@ -145,7 +146,8 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
|
||||
}
|
||||
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
|
||||
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) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
|
||||
import { analytics } from '$lib/analytics.ts';
|
||||
@@ -119,6 +120,8 @@
|
||||
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[] = [];
|
||||
@@ -334,11 +337,46 @@
|
||||
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;
|
||||
@@ -568,24 +606,13 @@
|
||||
throw new Error('서로 다른 멤버 5명과 성장 설정을 확인해 주세요.');
|
||||
}
|
||||
return { cards: context.cards, overallPower: context.unitPower.overallPower,
|
||||
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent };
|
||||
}
|
||||
|
||||
async function showRatingResult(chart: NonNullable<typeof selectedChart>, rankingResult: ExactOptimizationResult, power: number, probabilities: ScoreAchievementProbabilities) {
|
||||
ratingResultKey = ratingInputKey;
|
||||
selectedSongId = chart.songId;
|
||||
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' });
|
||||
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 {
|
||||
@@ -1068,12 +1095,13 @@
|
||||
{#if mode === 'rating'}
|
||||
<LeaderSongRanking {charts} leaderId={leaderCharacterId} leaderName={leaderMember?.name ?? ''}
|
||||
now={releaseNow} inputKey={ratingInputKey} disabled={!analysisReady || running || connectionOptimizationRunning || candidateCount === 0}
|
||||
getContext={ratingContext} onSelect={showRatingResult} />
|
||||
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">분석 결과</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 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}
|
||||
@@ -1247,6 +1275,7 @@
|
||||
</main>
|
||||
|
||||
<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; }
|
||||
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); }
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
|
||||
import { releasedSongCharts } from '$lib/data/song-release.ts';
|
||||
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 SongPicker from '$lib/components/SongPicker.svelte';
|
||||
import {
|
||||
@@ -82,6 +84,7 @@
|
||||
ownedCardsOnly?: boolean;
|
||||
maxMediumProbabilityCards?: number | null;
|
||||
maximizeCompute?: boolean;
|
||||
resultLimit?: number;
|
||||
leaderCharacterId: string;
|
||||
leaderOutfitCardId: string;
|
||||
};
|
||||
@@ -141,6 +144,8 @@
|
||||
let songScoreBonusOverride: number | null = 10;
|
||||
let ownedCardsOnly = false;
|
||||
let maxMediumProbabilityCards: number | null = null;
|
||||
let resultLimit = 10;
|
||||
let activeResultLimit = 10;
|
||||
let maximizeCompute = false;
|
||||
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
|
||||
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
|
||||
@@ -170,6 +175,43 @@
|
||||
let ready = false;
|
||||
let loadedUserData: string | null | undefined;
|
||||
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 {
|
||||
const raw = localStorage.getItem(USER_DATA_STORAGE_KEY);
|
||||
@@ -184,6 +226,7 @@
|
||||
}
|
||||
|
||||
function resetBaseline(): void {
|
||||
baselineTransfer = null;
|
||||
baselineWorker?.terminate();
|
||||
const nextWorker = createScoreOptimizerWorker();
|
||||
baselineWorker = nextWorker;
|
||||
@@ -226,7 +269,8 @@
|
||||
overallPower: baselineOverallPower,
|
||||
achievementProbabilities: baselineAchievementProbabilities,
|
||||
isBaseline: true
|
||||
} : null
|
||||
} : null,
|
||||
activeResultLimit
|
||||
);
|
||||
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
|
||||
$: if (!result && rankedResults.length > 0) rankedResults = [];
|
||||
@@ -262,6 +306,7 @@
|
||||
songScoreBonusOverride,
|
||||
ownedCardsOnly,
|
||||
maxMediumProbabilityCards,
|
||||
resultLimit,
|
||||
maximizeCompute,
|
||||
leaderCharacterId,
|
||||
leaderOutfitCardId
|
||||
@@ -280,6 +325,7 @@
|
||||
songScoreBonusOverride,
|
||||
ownedCardsOnly,
|
||||
maxMediumProbabilityCards,
|
||||
resultLimit,
|
||||
maximizeCompute,
|
||||
leaderCharacterId,
|
||||
leaderOutfitCardId
|
||||
@@ -416,6 +462,7 @@
|
||||
? null
|
||||
: normalizeSongScoreBonusOverride(state.songScoreBonusOverride) ?? 10;
|
||||
ownedCardsOnly = state.ownedCardsOnly === true;
|
||||
resultLimit = [10, 20, 30, 50].includes(state.resultLimit ?? 10) ? state.resultLimit ?? 10 : 10;
|
||||
maxMediumProbabilityCards = typeof state.maxMediumProbabilityCards === 'number' &&
|
||||
Number.isInteger(state.maxMediumProbabilityCards) && state.maxMediumProbabilityCards >= 0 && state.maxMediumProbabilityCards < 5
|
||||
? state.maxMediumProbabilityCards : null;
|
||||
@@ -902,6 +949,13 @@
|
||||
});
|
||||
const chart = await loadExactSongChart(chartMetadata.path);
|
||||
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;
|
||||
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
|
||||
baselineWorker.postMessage({
|
||||
@@ -951,7 +1005,7 @@
|
||||
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;
|
||||
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
|
||||
searchPhase = 'probability';
|
||||
@@ -976,7 +1030,7 @@
|
||||
exactPass = 'refinement';
|
||||
exactCombinationCount += refinements.length;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1028,12 +1082,12 @@
|
||||
if (completedWorkers !== workerCount) return;
|
||||
completedRankings.sort(compareRankedResults);
|
||||
parallelWorkers = [];
|
||||
completeExactPass(completedRankings.slice(0, 10));
|
||||
completeExactPass(completedRankings.slice(0, activeResultLimit));
|
||||
}
|
||||
if (event.data.type === 'error') fail(event.data.message);
|
||||
};
|
||||
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();
|
||||
// Rebuild reactive connection cards and candidate ownership before searching.
|
||||
await tick();
|
||||
if (running) return;
|
||||
} catch {
|
||||
errorMessage = '사용자 데이터를 읽지 못했습니다. 사용자 데이터 페이지에서 저장 상태를 확인해 주세요.';
|
||||
return;
|
||||
@@ -1065,6 +1120,10 @@
|
||||
return;
|
||||
}
|
||||
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';
|
||||
exactPass = 'primary';
|
||||
activeSearchChart = null;
|
||||
@@ -1090,7 +1149,7 @@
|
||||
activeSearchChart = chart;
|
||||
searchPhase = 'exact';
|
||||
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) {
|
||||
errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.';
|
||||
running = false;
|
||||
@@ -1141,7 +1200,8 @@
|
||||
|
||||
function buildDisplayedRankings(
|
||||
rankings: readonly RankedResult[],
|
||||
baseline: DisplayRankedResult | null
|
||||
baseline: DisplayRankedResult | null,
|
||||
limit = 10
|
||||
): DisplayRankedResult[] {
|
||||
if (rankings.length === 0) return [];
|
||||
const unique = new Map<string, DisplayRankedResult>();
|
||||
@@ -1155,7 +1215,7 @@
|
||||
}
|
||||
return Array.from(unique.values())
|
||||
.sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline))
|
||||
.slice(0, 10);
|
||||
.slice(0, limit);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1203,6 +1263,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
<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="song-fields">
|
||||
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
|
||||
@@ -1288,6 +1349,12 @@
|
||||
</div>
|
||||
<p class="hint">기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·가창 보너스 {songScoreBonusOverride === null ? '실제 보드값' : `${songScoreBonusOverride}%`} 조건으로 비교합니다.</p>
|
||||
<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}
|
||||
<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">
|
||||
@@ -1297,17 +1364,21 @@
|
||||
</label>
|
||||
<p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p>
|
||||
{#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
{#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}
|
||||
<div class="ranking-list">
|
||||
{#each displayedRankings as ranking, rank}
|
||||
<details class:baseline-ranking={ranking.isBaseline} class="ranking-card" open={rank === 0}>
|
||||
<summary>
|
||||
<svelte:element this={eventMode ? 'details' : 'article'} class:baseline-ranking={ranking.isBaseline} class="ranking-card" open={eventMode && rank === 0}>
|
||||
<svelte:element this={eventMode ? 'summary' : 'div'} class="ranking-summary">
|
||||
<span class="rank-number">{rank + 1}위</span>
|
||||
<div class="rank-team">
|
||||
<span class="rank-members">
|
||||
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
|
||||
{#each ranking.result.formationOrder as cardId, memberIndex}
|
||||
@@ -1318,17 +1389,25 @@
|
||||
· +{displayedCardAdditionalAwakenings(cardId, ranking.isBaseline)}회
|
||||
{/if}
|
||||
</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>
|
||||
{#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if}
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
{#if !eventMode}
|
||||
<div class="team-meta">
|
||||
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="rank-metric">
|
||||
<small>{eventMode ? '평균 이벤트 Pt 지수' : '최고 점수'}</small>
|
||||
<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 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 class="rank-probabilities">
|
||||
<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>95% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast95Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.95)}점</i></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">
|
||||
<AnalysisResultView
|
||||
result={ranking.result}
|
||||
@@ -1347,7 +1428,8 @@
|
||||
showScoreSummary={false}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</svelte:element>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p>
|
||||
@@ -1360,6 +1442,8 @@
|
||||
</main>
|
||||
|
||||
<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; }
|
||||
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; }
|
||||
@@ -1450,17 +1534,23 @@
|
||||
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
|
||||
.ranking-list { display: grid; gap: 10px; margin-top: 18px; }
|
||||
.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 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-card summary::-webkit-details-marker { display: none; }
|
||||
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
|
||||
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
|
||||
.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; }
|
||||
details.ranking-card > .ranking-summary { cursor: pointer; }
|
||||
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; }
|
||||
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-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; }
|
||||
.member-awakening { color: #8d96a5; font-size: 10px; font-weight: 750; line-height: 1.2; }
|
||||
.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 > i { flex: 0 0 auto; color: #596270; font-style: normal; }
|
||||
.rank-members .added-member { color: #f3a5c6; }
|
||||
@@ -1482,6 +1572,9 @@
|
||||
.awakening-guide { color: #aa8797; }
|
||||
.awakening-guide + .probability-guide { margin-top: 5px; }
|
||||
.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 div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; }
|
||||
.scores span { color: #a9b1be; font-size: 14px; }
|
||||
@@ -1489,6 +1582,12 @@
|
||||
.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; }
|
||||
@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-field { grid-template-columns: minmax(0, 1fr) 120px 304px 180px; }
|
||||
}
|
||||
@@ -1505,11 +1604,15 @@
|
||||
.leader-fields, .scores { grid-template-columns: 1fr; }
|
||||
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
|
||||
.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-members { grid-column: 2; grid-row: 1; }
|
||||
.rank-metric:nth-of-type(3) { 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; }
|
||||
.rank-team, article.ranking-card .rank-team { grid-column: 2; grid-row: 1; }
|
||||
.rank-metric { grid-column: 2; grid-row: 2; }
|
||||
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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user