Files
HolodoriCalc/src/lib/components/LeaderSongRanking.svelte
T
dal4segno d26b9ca884
Publish GitHub Pages output / publish (push) Successful in 54s
feat: streamline search results and open captured analyses
2026-09-09 19:15:25 +09:00

492 lines
34 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { 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>