492 lines
34 KiB
Svelte
492 lines
34 KiB
Svelte
<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';
|
||
import { perfectNoteScoreFromPower } from '$lib/engine/score.ts';
|
||
import { leaderRating, leaderRatingCharts, rankSongCharts, rankSongs, songRating } from '$lib/engine/song-rating.ts';
|
||
import type { ExactOptimizationResult, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/types.ts';
|
||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||
|
||
type Chart = (typeof catalog.charts)[number];
|
||
type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number; capture: AnalysisCapture };
|
||
type Evaluation = { result: ExactOptimizationResult; achievementProbabilities: ScoreAchievementProbabilities };
|
||
type Row = Chart & Evaluation & { transfer: AnalysisTransfer; overallPower: number; theoreticalMaxScore: number; expectedScore: number };
|
||
export let charts: Chart[];
|
||
export let leaderId: string;
|
||
export let leaderName: string;
|
||
export let now: number;
|
||
export let inputKey: string;
|
||
export let disabled = false;
|
||
export let getContext: (chart: Chart) => Context;
|
||
|
||
const difficulty = 'all';
|
||
let running = false;
|
||
let rows: Row[] = [];
|
||
let completed = 0;
|
||
let total = 0;
|
||
let maximizeCompute = false;
|
||
let reportedCores = 1;
|
||
let activeJobs: Record<number, { title: string; fraction: number }> = {};
|
||
let message = '';
|
||
let transferError = '';
|
||
let failures: string[] = [];
|
||
let snapshotKey = '';
|
||
let snapshotLeader = '';
|
||
const workers = new Set<Worker>();
|
||
const pendingJobs = new Set<(reason: Error) => void>();
|
||
let runId = 0;
|
||
let pageNumber = 1;
|
||
const pageSize = 5;
|
||
let resultList: HTMLDivElement;
|
||
|
||
onMount(() => {
|
||
const cores = navigator.hardwareConcurrency || 4;
|
||
reportedCores = Number.isFinite(cores) ? Math.max(1, Math.floor(cores)) : 1;
|
||
});
|
||
|
||
$: targets = leaderRatingCharts(charts, leaderId, difficulty, now);
|
||
$: parallelCount = Math.min(targets.length, reportedCores);
|
||
$: activeEntries = Object.values(activeJobs);
|
||
$: fraction = activeEntries.reduce((sum, job) => sum + job.fraction, 0);
|
||
$: currentTitle = activeEntries.length
|
||
? `${activeEntries[0].title}${activeEntries.length > 1 ? ` 외 ${activeEntries.length - 1}개` : ''}` : '';
|
||
$: currentKey = JSON.stringify([inputKey, difficulty, targets.map((chart) => chart.path)]);
|
||
$: stale = snapshotKey !== '' && snapshotKey !== currentKey;
|
||
$: rankings = rankSongCharts(rows).slice(0, 20);
|
||
$: topSongs = rankSongs(rows).slice(0, 3);
|
||
$: pageCount = Math.max(1, Math.ceil(rankings.length / pageSize));
|
||
$: if (pageNumber > pageCount) pageNumber = pageCount;
|
||
$: pageRows = rankings.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
|
||
$: if (running && stale) cancel('입력 조건이 변경되어 계산을 중단했습니다. 다시 검색해 주세요.');
|
||
|
||
function cancel(reason = '계산을 취소했습니다. 완료된 곡까지만 표시합니다.'): void {
|
||
runId += 1;
|
||
for (const worker of workers) worker.terminate();
|
||
workers.clear();
|
||
for (const reject of pendingJobs) reject(new Error(reason));
|
||
pendingJobs.clear();
|
||
activeJobs = {};
|
||
running = false;
|
||
message = reason;
|
||
}
|
||
|
||
onDestroy(() => cancel());
|
||
|
||
async function search(): Promise<void> {
|
||
if (running || disabled || targets.length === 0) return;
|
||
const id = ++runId;
|
||
snapshotKey = currentKey;
|
||
snapshotLeader = leaderName;
|
||
rows = [];
|
||
pageNumber = 1;
|
||
failures = [];
|
||
message = '';
|
||
completed = 0;
|
||
activeJobs = {};
|
||
total = targets.length;
|
||
running = true;
|
||
try {
|
||
// Capture every song's board/power context before the first async boundary.
|
||
const jobs = targets.map((chart) => ({ chart, context: structuredClone(getContext(chart)) }));
|
||
const workerCount = maximizeCompute ? Math.min(jobs.length, reportedCores) : 1;
|
||
let nextJob = 0;
|
||
async function consume(): Promise<void> {
|
||
while (id === runId && nextJob < jobs.length) {
|
||
const jobIndex = nextJob++;
|
||
const { chart: summary, context } = jobs[jobIndex];
|
||
const title = `${summary.title} · ${difficultyLabel(summary.difficulty)}`;
|
||
activeJobs = { ...activeJobs, [jobIndex]: { title, fraction: 0 } };
|
||
let jobWorker: Worker | null = null;
|
||
let rejectJob: ((reason: Error) => void) | null = null;
|
||
try {
|
||
const chart = await loadExactSongChart(summary.path);
|
||
if (id !== runId) return;
|
||
const { result, achievementProbabilities } = await new Promise<Evaluation>((resolve, reject) => {
|
||
rejectJob = reject;
|
||
pendingJobs.add(reject);
|
||
jobWorker = createScoreOptimizerWorker();
|
||
workers.add(jobWorker);
|
||
jobWorker.onmessage = (event) => {
|
||
if (id !== runId) return;
|
||
if (event.data.type === 'progress') {
|
||
const progress = event.data.progress;
|
||
activeJobs = { ...activeJobs, [jobIndex]: { title,
|
||
fraction: .9 * Math.min(1, Math.max(0, progress.evaluatedCandidates / Math.max(1, progress.totalCandidates))) } };
|
||
} else if (event.data.type === 'probability-start') {
|
||
activeJobs = { ...activeJobs, [jobIndex]: { title: `${title} · 확률 계산`, fraction: .9 } };
|
||
} else if (event.data.type === 'result') resolve({ result: event.data.result, achievementProbabilities: event.data.achievementProbabilities });
|
||
else if (event.data.type === 'error') reject(new Error(event.data.message));
|
||
};
|
||
jobWorker.onerror = () => reject(new Error('계산 작업을 실행하지 못했습니다.'));
|
||
jobWorker.postMessage({
|
||
type: 'optimize-exact', cards: context.cards, chart,
|
||
perfectNoteScore: perfectNoteScoreFromPower(context.overallPower, chart.scoreRatio),
|
||
directSongScoreBonusPercent: context.directSongScoreBonusPercent,
|
||
includeAchievementProbabilities: true
|
||
});
|
||
});
|
||
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;
|
||
failures = [...failures, `${title}: ${error instanceof Error ? error.message : '계산 실패'}`];
|
||
} finally {
|
||
if (jobWorker) {
|
||
(jobWorker as Worker).terminate();
|
||
workers.delete(jobWorker);
|
||
}
|
||
if (rejectJob) pendingJobs.delete(rejectJob);
|
||
if (id === runId) {
|
||
const remaining = { ...activeJobs };
|
||
delete remaining[jobIndex];
|
||
activeJobs = remaining;
|
||
completed += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
await Promise.all(Array.from({ length: workerCount }, () => consume()));
|
||
if (id !== runId) return;
|
||
message = failures.length ? `계산 종료 · ${failures.length}개 채보 실패. 순위와 합계는 일부 결과입니다.` : '모든 대상 곡의 계산을 완료했습니다.';
|
||
} catch (error) {
|
||
if (id !== runId) return;
|
||
message = error instanceof Error ? error.message : '입력 조건을 확인해 주세요.';
|
||
} finally {
|
||
if (id === runId) running = false;
|
||
}
|
||
}
|
||
|
||
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%';
|
||
if (value < 0.0001) return `${(value * 100).toFixed(4)}%`;
|
||
if (value < 0.01) return `${(value * 100).toFixed(2)}%`;
|
||
return `${(value * 100).toFixed(1)}%`;
|
||
}
|
||
|
||
function changePage(value: number): void {
|
||
pageNumber = Math.max(1, Math.min(pageCount, value));
|
||
resultList?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
</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>
|
||
|
||
<div class="search-bar">
|
||
<div class="leader-summary"><span class="eyebrow">선택한 리더</span><strong>{leaderName}</strong></div>
|
||
<div class="conditions">
|
||
<div><span>가창곡</span><strong>{new Set(targets.map((chart) => chart.songId)).size}<small>곡</small></strong></div>
|
||
<div><span>분석 채보</span><strong>{targets.length}<small>개</small></strong></div>
|
||
<div><span>가창 보너스</span><strong class="bonus">10<small>%</small></strong></div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="primary" type="button" on:click={search} disabled={disabled || running || targets.length === 0}><span>{running ? '추천 곡 계산 중' : '레이팅 높은 곡 찾기'}</span><span aria-hidden="true">{running ? '···' : '↗'}</span></button>
|
||
{#if running}<button class="cancel" type="button" on:click={() => cancel()}>계산 취소</button>{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="compute-settings">
|
||
<label class="compute-option">
|
||
<input type="checkbox" bind:checked={maximizeCompute} disabled={running} aria-describedby="rating-compute-help">
|
||
<span class="compute-switch" aria-hidden="true"></span>
|
||
<strong>최대 컴퓨팅 자원 활용</strong>
|
||
<span class="compute-count">{maximizeCompute ? `최대 ${parallelCount}개 동시 계산` : '한 번에 1개 계산'}</span>
|
||
</label>
|
||
<p id="rating-compute-help">기기의 논리 코어 수에 맞춰 여러 채보를 동시에 계산합니다. 계산 결과는 동일하며, 사용 중 발열이나 브라우저 반응 저하가 생길 수 있습니다.</p>
|
||
</div>
|
||
|
||
{#if running}
|
||
<div class="progress-panel" role="status">
|
||
<div class="progress-heading"><span><i class="status-dot"></i>최적 배치를 비교하고 있어요</span><strong>{Math.round((completed + fraction) / Math.max(1, total) * 100)}<small>%</small></strong></div>
|
||
<progress aria-label="리더 레이팅 곡 계산 진행률" value={completed + fraction} max={total}></progress>
|
||
<div class="progress-caption"><span>{currentTitle || '채보 준비 중'}</span><span>{activeEntries.length}개 동시 계산 · {completed} / {total}개 완료</span></div>
|
||
</div>
|
||
{/if}
|
||
{#if message}<p class="status-message" role="status"><span class="status-dot"></span>{message}</p>{/if}
|
||
{#if stale}<p class="warning" role="status">입력 조건이 변경되었습니다. 아래는 이전 조건의 결과이므로 다시 검색해 주세요.</p>{/if}
|
||
{#if failures.length}<details class="failure-details"><summary>계산하지 못한 채보 {failures.length}개</summary><ul>{#each failures as failure}<li>{failure}</li>{/each}</ul></details>{/if}
|
||
|
||
{#if rankings.length}
|
||
<div class="rating-summary">
|
||
<div class="total">
|
||
<span class="eyebrow">{snapshotLeader} · 예상 리더 레이팅</span>
|
||
<div class="rating-value"><strong>{format(leaderRating(rows))}</strong><span>RATING</span></div>
|
||
<span class="total-caption">상위 {topSongs.length}곡 합산{running || completed < total || failures.length ? ' · 일부 결과' : ''}{stale ? ' · 이전 조건' : ''}</span>
|
||
</div>
|
||
<div class="contributors">
|
||
<div class="contributors-title"><span>레이팅에 합산되는 곡</span><small>곡별 최고 기록 1개</small></div>
|
||
<ol>{#each topSongs as row, index}<li>
|
||
<span class="contributor-index">{String(index + 1).padStart(2, '0')}</span>
|
||
<div><span class="contributor-title">{row.title}</span><small>{difficultyLabel(row.difficulty)} · Lv.{row.difficultyLevel}</small></div>
|
||
<strong>{format(songRating(row.theoreticalMaxScore))}</strong>
|
||
</li>{/each}</ol>
|
||
</div>
|
||
</div>
|
||
<div class="list-heading"><h3>추천 순위 <span>{rankings.length}</span></h3><p>예상 고점 레이팅순 <span>·</span> 5개씩 보기</p></div>
|
||
<div class="table-scroll" bind:this={resultList}>
|
||
<table>
|
||
<thead><tr><th scope="col">순위</th><th scope="col">곡 / 난이도</th><th scope="col">고점 레이팅</th><th scope="col">예상 고점</th><th scope="col">해당 배치 평균</th><th scope="col">점수 달성 확률</th><th scope="col"><span class="sr-only">상세 배치</span></th></tr></thead>
|
||
<tbody>{#each pageRows as row, index}<tr class:top={topSongs.includes(row)}>
|
||
<td class="rank-cell"><span class="rank-number" class:first={(pageNumber - 1) * pageSize + index === 0}>{(pageNumber - 1) * pageSize + index + 1}</span></td>
|
||
<td class="song"><strong>{row.title}</strong><div class="song-meta"><span class="difficulty" class:expert={row.difficulty === 'expert'}>{difficultyLabel(row.difficulty)} <b>{row.difficultyLevel}</b></span>{#if topSongs.includes(row)}<span class="counted">합산 대상</span>{/if}{#if row.scoreCalibrationSource !== 'formula-v1' && row.scoreCalibrationSource !== 'measured'}<span class="estimated">점수 계수 추정</span>{/if}</div></td>
|
||
<td class="chart-rating"><span class="mobile-label">레이팅</span><strong>{format(songRating(row.theoreticalMaxScore))}</strong></td>
|
||
<td class="maximum-score"><span class="mobile-label">예상 고점</span>{format(row.theoreticalMaxScore)}</td>
|
||
<td class="average-score"><span class="mobile-label">배치 평균</span>{format(row.expectedScore)}</td>
|
||
<td class="probability-cell"><div class="achievement-grid">
|
||
<div title={`${format(row.theoreticalMaxScore)}점 달성 확률`}><span>최고점</span><strong>{probabilityLabel(row.achievementProbabilities.maximum)}</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></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>
|
||
<div class="list-footer">
|
||
<span class="range-label" role="status">{(pageNumber - 1) * pageSize + 1}–{Math.min(pageNumber * pageSize, rankings.length)} <span>/ {rankings.length}개 채보</span></span>
|
||
<nav class="pagination" aria-label="추천 곡 페이지">
|
||
<button class="page-arrow" type="button" aria-label="이전" disabled={pageNumber === 1} on:click={() => changePage(pageNumber - 1)}>‹</button>
|
||
{#each Array(pageCount) as _, index}<button type="button" aria-label={`${index + 1}페이지`} aria-current={pageNumber === index + 1 ? 'page' : undefined} on:click={() => changePage(index + 1)}>{index + 1}</button>{/each}
|
||
<button class="page-arrow" type="button" aria-label="다음" disabled={pageNumber === pageCount} on:click={() => changePage(pageNumber + 1)}>›</button>
|
||
</nav>
|
||
</div>
|
||
{:else if !running && !message}
|
||
<div class="empty-state">
|
||
<div class="empty-chart" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></div>
|
||
<h3>{targets.length ? '이 편성의 추천곡을 확인하세요' : '분석할 가창곡이 없습니다'}</h3>
|
||
<p>{targets.length ? '곡마다 최적 배치를 계산해 레이팅이 높은 순서로 보여드립니다.' : '선택한 리더에 해당하는 공개된 가창 채보가 없습니다.'}</p>
|
||
{#if targets.length}<span class="empty-caption">예상 레이팅 · 최적 배치 · 상위 3곡 합계</span>{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<details class="calculation-guide">
|
||
<summary><span class="info-mark" aria-hidden="true">i</span>계산 기준 및 안내<span class="guide-chevron" aria-hidden="true">⌄</span></summary>
|
||
<div>
|
||
<p>멤버 5명과 리더·의상·성장 설정을 반영하고, 곡마다 스페셜 순서와 허용된 빈도 감소를 최적화합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.</p>
|
||
<p>ALL PERFECT·스킬 최대 발동 기준의 예상 고점 순위입니다. 곡별 Easy·Normal·Hard·Expert를 각각 표시하며, 최대 20개 결과를 5개씩 확인할 수 있습니다. 상위 3곡 합계는 같은 곡에서 고점이 높은 결과 하나만 집계합니다.</p>
|
||
<p>점수 달성 확률은 해당 추천 배치에서 모든 노트를 PERFECT로 처리할 때의 액티브 발동 확률을 반영합니다. 최고점 확률은 정확 계산, 최고점의 99%·95% 이상 확률은 25,000회 반복 계산값입니다.</p>
|
||
<p>곡 레이팅 = 점수 ÷ 5,000 올림 · <a href="https://dreams.wf-calc.net/wiki/live" target="_blank" rel="noreferrer">계산 기준 (비공식 연구 자료) ↗</a>. 실제 플레이 결과와 차이가 있을 수 있습니다. 가창 멤버가 등록되지 않은 곡은 제외합니다.</p>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
|
||
<style>
|
||
.ranking { margin-top: 22px; padding: 28px; border: 1px solid #34313f; border-radius: 16px; background: #171b23; color: #e6e9f0; }
|
||
.heading, .section-heading, .search-bar, .actions, .progress-heading, .progress-caption, .list-heading, .list-footer, .pagination { display: flex; align-items: center; }
|
||
.heading { justify-content: space-between; gap: 16px; margin-bottom: 24px; }
|
||
.section-heading { gap: 13px; }
|
||
.section-number { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border: 1px solid #624052; border-radius: 11px; background: #32232e; color: #efa4c6; font-size: 13px; font-weight: 850; }
|
||
h2 { margin: 0; font-size: 19px; letter-spacing: -.035em; }
|
||
.heading p { margin: 6px 0 0; color: #909aab; font-size: 13px; }
|
||
.search-bar { gap: 24px; padding: 20px 22px; border: 1px solid #34323f; border-radius: 12px; background: linear-gradient(110deg, #25212e, #1d222c 75%); }
|
||
.leader-summary { display: grid; gap: 7px; min-width: 120px; }
|
||
.eyebrow { color: #a69ead; font-size: 12px; font-weight: 650; }
|
||
.leader-summary > strong { font-size: 19px; letter-spacing: -.025em; }
|
||
.conditions { display: flex; gap: clamp(20px, 3vw, 44px); padding-left: 24px; border-left: 1px solid #3c3644; }
|
||
.conditions > div { display: grid; gap: 7px; }
|
||
.conditions > div > span { color: #929aab; font-size: 11px; white-space: nowrap; }
|
||
.conditions strong { font-size: 21px; font-variant-numeric: tabular-nums; }
|
||
.conditions small { margin-left: 3px; color: #9da7b6; font-size: 12px; font-weight: 500; }
|
||
.conditions .bonus, .conditions .bonus small { color: #eba2c2; }
|
||
.actions { gap: 9px; margin-left: auto; flex-wrap: wrap; justify-content: flex-end; }
|
||
button { border: 1px solid #3b4452; border-radius: 8px; background: #222936; color: #cad3e1; font: inherit; font-size: 12px; cursor: pointer; transition: background .15s, border-color .15s; }
|
||
button:hover:not(:disabled) { background: #303344; border-color: #7d526b; color: #f4c0d8; }
|
||
button:disabled { opacity: .4; cursor: default; }
|
||
button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px solid #f1a0c4; outline-offset: 4px; }
|
||
.primary { display: flex; justify-content: space-between; align-items: center; gap: 24px; min-height: 45px; padding: 11px 16px; border-color: #eda0c2; background: linear-gradient(110deg, #f2abc9, #df8db4); color: #291822; font-size: 13px; font-weight: 850; box-shadow: 0 4px 18px #dc7fad15; }
|
||
.primary > span:last-child { font-size: 20px; font-weight: 500; }
|
||
.primary:hover:not(:disabled) { background: #f5b6d2; color: #291822; border-color: #f5b6d2; }
|
||
.cancel { padding: 10px 12px; }
|
||
.compute-settings { margin: 15px 2px 0; }
|
||
.compute-option { position: relative; display: flex; align-items: center; gap: 9px; width: fit-content; flex-wrap: wrap; cursor: pointer; }
|
||
.compute-option input { position: absolute; z-index: 1; top: 0; left: 0; width: 30px; height: 18px; margin: 0; opacity: 0; cursor: pointer; }
|
||
.compute-switch { position: relative; width: 30px; height: 18px; border: 1px solid #555064; border-radius: 20px; background: #2a2935; }
|
||
.compute-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 10px; height: 10px; border-radius: 50%; background: #9690a2; }
|
||
.compute-option input:checked + .compute-switch { border-color: #df94b7; background: #a66a8b; }
|
||
.compute-option input:checked + .compute-switch::after { left: 15px; background: #ffe0ee; }
|
||
.compute-option input:focus-visible + .compute-switch { outline: 2px solid #f1a0c4; outline-offset: 4px; }
|
||
.compute-option input:disabled ~ span, .compute-option input:disabled ~ strong { opacity: .5; cursor: default; }
|
||
.compute-option strong { color: #d2c6d6; font-size: 12px; font-weight: 650; }
|
||
.compute-count { color: #bc8aa8; font-size: 10px; }
|
||
.compute-settings p { margin: 7px 0 0 39px; color: #9b8c9e; font-size: 11px; line-height: 1.65; }
|
||
.progress-panel { margin-top: 20px; padding: 18px; border: 1px solid #423244; border-radius: 10px; background: #221e2a; }
|
||
.progress-heading { justify-content: space-between; gap: 12px; font-size: 13px; }
|
||
.progress-heading > span { display: flex; align-items: center; gap: 8px; }
|
||
.progress-heading strong { color: #eea2c5; font-size: 23px; font-variant-numeric: tabular-nums; }
|
||
.progress-heading small { font-size: 12px; }
|
||
progress { width: 100%; height: 6px; margin: 12px 0; border: none; border-radius: 9px; appearance: none; background: #3b2d3e; accent-color: #ec9bbe; overflow: hidden; }
|
||
progress::-webkit-progress-bar { background: #3b2d3e; }
|
||
progress::-webkit-progress-value { background: linear-gradient(90deg, #b87dab, #f0a7c9); border-radius: 9px; }
|
||
progress::-moz-progress-bar { background: #f0a7c9; }
|
||
.progress-caption { justify-content: space-between; gap: 16px; color: #a99ab2; font-size: 11px; }
|
||
.progress-caption > span:last-child { flex: none; }
|
||
.status-message { display: flex; align-items: center; gap: 8px; margin: 16px 2px 0; color: #9eafbb; font-size: 12px; line-height: 1.6; }
|
||
.status-dot { display: inline-block; flex: none; width: 5px; height: 5px; border-radius: 50%; background: #bf92b3; }
|
||
.warning { padding: 12px 16px; border: 1px solid #684d31; border-radius: 8px; background: #2c241c; color: #efbb7e; font-size: 13px; line-height: 1.6; }
|
||
.failure-details { margin-top: 16px; color: #e5b184; font-size: 12px; }
|
||
.rating-summary { display: grid; grid-template-columns: minmax(220px, .85fr) minmax(0, 1.4fr); gap: 32px; margin: 22px 0 28px; padding: 26px; border: 1px solid #53364a; border-radius: 13px; background: radial-gradient(ellipse at 0% 10%, #5c324c40, transparent 65%), #211d29; }
|
||
.total { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; gap: 9px; }
|
||
.rating-value { display: flex; align-items: baseline; gap: 11px; }
|
||
.rating-value > strong { color: #f5b5d1; font-size: clamp(38px, 4vw, 52px); line-height: 1.1; font-weight: 850; letter-spacing: -.05em; font-variant-numeric: tabular-nums; }
|
||
.rating-value > span { color: #a77995; font-size: 10px; font-weight: 800; letter-spacing: .12em; }
|
||
.total-caption { color: #a794aa; font-size: 11px; }
|
||
.contributors { padding-left: 28px; border-left: 1px solid #493244; min-width: 0; }
|
||
.contributors-title { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 13px; color: #d3b7cc; font-size: 12px; font-weight: 700; }
|
||
.contributors-title small { color: #937f97; font-size: 10px; font-weight: 400; }
|
||
.contributors ol { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||
.contributors li { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||
.contributor-index { color: #99768e; font-size: 10px; font-weight: 800; }
|
||
.contributors li > div { display: grid; flex: 1; min-width: 0; gap: 3px; }
|
||
.contributor-title { color: #d9d1df; font-size: 12px; overflow-wrap: anywhere; }
|
||
.contributors li small { color: #948699; font-size: 10px; }
|
||
.contributors li > strong { color: #eeb0ce; font-size: 16px; font-variant-numeric: tabular-nums; }
|
||
.list-heading { justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||
.list-heading h3 { margin: 0; font-size: 15px; }
|
||
.list-heading h3 > span { margin-left: 5px; color: #e4a0bf; font-size: 12px; }
|
||
.list-heading p { margin: 0; color: #9099aa; font-size: 11px; }
|
||
.list-heading p > span { margin: 0 5px; color: #525967; }
|
||
.table-scroll { overflow: auto; max-height: 570px; scroll-margin-top: 84px; border: 1px solid #313744; border-radius: 11px; scrollbar-width: thin; scrollbar-color: #655062 #1b2029; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: right; font-variant-numeric: tabular-nums; }
|
||
th { position: sticky; top: 0; z-index: 1; padding: 12px 16px; background: #202630; color: #949eae; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
||
th:first-child { width: 62px; text-align: center; }
|
||
th:nth-child(2) { text-align: left; }
|
||
td { padding: 17px 16px; border-top: 1px solid #303541; white-space: nowrap; background: #191f28; }
|
||
.top td { background: #241f2c; }
|
||
.top td:first-child { box-shadow: inset 2px 0 #ca87ae; }
|
||
.rank-cell { text-align: center; }
|
||
.rank-number { display: inline-grid; place-items: center; width: 29px; height: 29px; color: #8b95a6; font-size: 12px; font-weight: 750; }
|
||
.rank-number.first { background: #e9a1c1; border-radius: 8px; color: #33212c; }
|
||
.song { min-width: 190px; max-width: 420px; text-align: left; white-space: normal; }
|
||
.song > strong { display: block; color: #e3e5ed; font-size: 13px; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; }
|
||
.song-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 7px; }
|
||
.difficulty { padding: 3px 6px; border-radius: 4px; background: #383021; color: #d8b477; font-size: 9px; line-height: 1.25; }
|
||
.difficulty.expert { background: #392c43; color: #c2a2e0; }
|
||
.difficulty b { margin-left: 4px; font-weight: 650; }
|
||
.counted { color: #c38faa; font-size: 9px; }
|
||
.estimated { color: #c5a078; font-size: 9px; }
|
||
.chart-rating strong { color: #f1b0ce; font-size: 23px; letter-spacing: -.025em; }
|
||
.maximum-score { color: #cdd5e1; }
|
||
.average-score { color: #8e9aaf; }
|
||
.achievement-grid { display: grid; grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 10px; text-align: right; }
|
||
.achievement-grid > div { display: grid; gap: 7px; }
|
||
.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 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; }
|
||
.range-label > span { color: #808a9c; }
|
||
.pagination { gap: 5px; }
|
||
.pagination button { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; background: transparent; border-color: transparent; font-size: 11px; }
|
||
.pagination .page-arrow { border-color: #343c49; font-size: 20px; }
|
||
.pagination [aria-current='page'] { background: #382735; border-color: #80556e; color: #f0a6c7; font-weight: 800; }
|
||
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 38px 16px; margin-top: 20px; border: 1px dashed #383744; border-radius: 12px; background: radial-gradient(ellipse at center, #33263430, transparent 70%); text-align: center; }
|
||
.empty-chart { display: flex; align-items: flex-end; gap: 5px; height: 33px; margin-bottom: 20px; }
|
||
.empty-chart span { width: 7px; height: 12px; border-radius: 3px; background: #665267; }
|
||
.empty-chart span:nth-child(2) { height: 23px; background: #97708e; }
|
||
.empty-chart span:nth-child(3) { height: 33px; background: #d396b6; }
|
||
.empty-chart span:nth-child(4) { height: 26px; background: #a07896; }
|
||
.empty-chart span:nth-child(5) { height: 17px; background: #735a73; }
|
||
.empty-state h3 { margin: 0; color: #d5cfde; font-size: 15px; font-weight: 650; }
|
||
.empty-state p { margin: 9px 0 16px; color: #8d96a8; font-size: 12px; line-height: 1.7; }
|
||
.empty-caption { color: #af809f; font-size: 10px; letter-spacing: .025em; }
|
||
.calculation-guide { margin-top: 22px; border-top: 1px solid #30343f; color: #909bad; font-size: 12px; }
|
||
.calculation-guide summary { display: flex; align-items: center; gap: 8px; padding-top: 16px; list-style: none; cursor: pointer; }
|
||
.calculation-guide summary::-webkit-details-marker { display: none; }
|
||
.info-mark { display: inline-grid; place-items: center; width: 15px; height: 15px; border: 1px solid #707083; border-radius: 50%; font-size: 10px; font-style: italic; }
|
||
.guide-chevron { margin-left: auto; color: #b99bb0; }
|
||
.calculation-guide[open] .guide-chevron { transform: rotate(180deg); }
|
||
.calculation-guide > div { padding: 6px 0 0 23px; }
|
||
.calculation-guide p { margin: 10px 0 0; line-height: 1.8; }
|
||
a { color: #dba1c0; }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||
@media (max-width: 1000px) {
|
||
.search-bar { flex-wrap: wrap; gap: 20px; }
|
||
.leader-summary { flex: 1; }
|
||
.actions { flex-basis: 100%; }
|
||
.primary { flex: 1; }
|
||
td, th { padding-left: 12px; padding-right: 12px; }
|
||
}
|
||
@media (max-width: 700px) {
|
||
.ranking { padding: 18px 14px; }
|
||
.heading { align-items: flex-start; gap: 8px; margin-bottom: 18px; }
|
||
.section-heading { gap: 9px; align-items: flex-start; }
|
||
.section-number { width: 30px; height: 30px; border-radius: 8px; font-size: 11px; }
|
||
h2 { font-size: 16px; }
|
||
.heading p { font-size: 11px; line-height: 1.6; }
|
||
.search-bar { padding: 16px; gap: 17px; }
|
||
.leader-summary { flex-basis: 100%; gap: 5px; }
|
||
.leader-summary > strong { font-size: 18px; }
|
||
.conditions { flex: 1; justify-content: space-between; gap: 18px; border-left: 0; padding-left: 0; }
|
||
.actions { margin: 0; }
|
||
.primary { min-height: 44px; }
|
||
.rating-summary { grid-template-columns: 1fr; gap: 21px; padding: 21px 18px; }
|
||
.rating-value > strong { font-size: 43px; }
|
||
.contributors { padding: 19px 0 0; border-left: 0; border-top: 1px solid #493244; }
|
||
.contributors-title small { font-size: 9px; }
|
||
.list-heading p { font-size: 10px; }
|
||
.list-heading p > span { margin: 0 2px; }
|
||
.table-scroll { max-height: 680px; background: #171b23; border: 0; border-radius: 0; }
|
||
table, tbody { display: block; }
|
||
thead { display: none; }
|
||
tbody { display: grid; gap: 9px; }
|
||
tbody tr { display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; gap: 7px 9px; padding: 15px 12px; border: 1px solid #303541; border-radius: 10px; background: #191f28; }
|
||
tbody tr.top { background: #241f2c; border-color: #55394c; }
|
||
td, .top td { padding: 0; border: 0; background: transparent; }
|
||
.top td:first-child { box-shadow: none; }
|
||
.rank-cell { grid-column: 1; grid-row: 1 / 4; padding-top: 1px; }
|
||
.rank-number { width: 25px; height: 25px; font-size: 11px; }
|
||
.song { grid-column: 2; grid-row: 1; min-width: 0; max-width: none; }
|
||
.song > strong { font-size: 12px; }
|
||
.song-meta { gap: 5px; margin: 6px 0 4px; }
|
||
.chart-rating { grid-column: 3; grid-row: 1; min-width: 51px; }
|
||
.chart-rating .mobile-label { display: block; margin: 0 0 3px; font-size: 9px; }
|
||
.chart-rating strong { font-size: 24px; }
|
||
.maximum-score { grid-column: 2; grid-row: 2; 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; }
|
||
.row-action { grid-column: 3; grid-row: 2 / 4; align-self: end; }
|
||
.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; }
|
||
.list-footer { gap: 5px; }
|
||
.pagination { gap: 2px; }
|
||
.pagination button { width: 29px; height: 31px; }
|
||
.range-label { font-size: 10px; }
|
||
.empty-state { padding: 30px 14px; }
|
||
.empty-state h3 { font-size: 14px; }
|
||
.empty-state p { font-size: 11px; }
|
||
.progress-panel { padding: 14px; }
|
||
.progress-heading { font-size: 11px; }
|
||
.progress-caption { align-items: flex-start; font-size: 10px; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { button { transition: none; } }
|
||
</style>
|