Files
HolodoriCalc/src/routes/tools/+page.svelte
T
dal4segno 50421280fa
Publish GitHub Pages output / publish (push) Successful in 53s
feat: add random song tool with level filters and shareable history
2026-09-10 21:33:43 +09:00

215 lines
12 KiB
Svelte

<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { difficultyLabel } from '$lib/data/difficulty.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import { formatRandomSongResults, pickRandomSong, randomSongCandidates } from '$lib/engine/random-song.ts';
type Chart = (typeof exactChartCatalog.charts)[number];
let releaseClock = createInitialReleaseClock($page.data?.serverNow);
let releaseNow = releaseClock.now();
let useLevelRange = false;
let minLevel: number | undefined;
let maxLevel: number | undefined;
let results: Chart[] = [];
let announcement = '';
let copying = false;
let copyStatus = '';
let showCopyText = false;
let copyTextArea: HTMLTextAreaElement | undefined;
$: resultsText = formatRandomSongResults(results);
$: charts = releasedSongCharts(exactChartCatalog.charts, releaseNow);
$: levels = [...new Set(charts.map((chart) => chart.difficultyLevel)
.filter((level): level is number => typeof level === 'number' && Number.isFinite(level)))].sort((a, b) => a - b);
$: if (minLevel === undefined && levels.length) minLevel = levels[0];
$: if (maxLevel === undefined && levels.length) maxLevel = levels[levels.length - 1];
$: invalidRange = useLevelRange && (minLevel === undefined || maxLevel === undefined || minLevel > maxLevel);
$: candidates = randomSongCandidates(charts, useLevelRange ? { min: minLevel ?? NaN, max: maxLevel ?? NaN } : undefined);
$: songCount = new Set(candidates.map((chart) => chart.songId)).size;
function draw() {
const selected = pickRandomSong(candidates);
if (!selected) return;
results = [...results, selected];
copyStatus = '';
showCopyText = false;
announcement = `${results.length}번째 추첨: ${selected.title}, ${difficultyLabel(selected.difficulty)}, ${selected.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${selected.difficultyLevel}`}`;
}
function clearResults() {
results = [];
copyStatus = '';
showCopyText = false;
announcement = '추첨 목록을 초기화했습니다.';
}
async function copyResults() {
if (!results.length || copying) return;
const snapshot = results;
const text = resultsText;
copying = true;
copyStatus = '';
showCopyText = false;
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(text);
if (results === snapshot) copyStatus = `${snapshot.length}개 결과를 복사했습니다.`;
} catch {
if (results !== snapshot) return;
copyStatus = '자동 복사를 사용할 수 없습니다. 아래 텍스트를 선택해 복사해 주세요.';
showCopyText = true;
await tick();
copyTextArea?.focus();
copyTextArea?.select();
} finally {
copying = false;
}
}
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
releaseClock = clock;
releaseNow = clock.now();
});
const timer = window.setInterval(() => releaseNow = releaseClock.now(), 1_000);
return () => { mounted = false; window.clearInterval(timer); };
});
</script>
<svelte:head>
<title>악곡 랜덤 · 도구 · 홀로도리 계산기</title>
<meta name="description" content="모든 악곡과 난이도에서 무작위로 선택하세요. 원하는 레벨 범위로 추첨할 수 있습니다." />
</svelte:head>
<main>
<header>
<h1>도구</h1>
<p>플레이할 때 유용한 도구를 모았습니다.</p>
</header>
<nav class="tools-nav" aria-label="도구 메뉴">
<a href="/tools/random" aria-current="page">악곡 랜덤</a>
</nav>
<div class="tool-heading">
<h2>악곡 랜덤</h2>
<p>모든 악곡의 모든 난이도에서 악곡·난이도 한 조합을 무작위로 선택합니다.</p>
</div>
<section class="panel" aria-label="추첨 조건">
<label class="toggle"><input type="checkbox" bind:checked={useLevelRange}> 레벨 범위 지정</label>
{#if useLevelRange}
<div class="range">
<label>최소 레벨
<select bind:value={minLevel} aria-invalid={invalidRange} aria-describedby="candidate-status">
{#each levels as level}<option value={level}>Lv. {level}</option>{/each}
</select>
</label>
<span class="separator" aria-hidden="true">~</span>
<label>최대 레벨
<select bind:value={maxLevel} aria-invalid={invalidRange} aria-describedby="candidate-status">
{#each levels as level}<option value={level}>Lv. {level}</option>{/each}
</select>
</label>
</div>
<p class="hint">최소·최대 레벨을 포함합니다. 같은 레벨로 지정하면 해당 레벨만 추첨합니다.</p>
{/if}
<p id="candidate-status" class:error={invalidRange || candidates.length === 0} aria-live="polite">
{#if invalidRange}최소 레벨은 최대 레벨 이하로 지정해 주세요.
{:else if candidates.length === 0}조건에 맞는 악곡·난이도가 없습니다. 레벨 범위를 변경해 주세요.
{:else}{songCount}곡 · {candidates.length}개 악곡·난이도 조합에서 추첨{/if}
</p>
<button type="button" on:click={draw} disabled={candidates.length === 0}>{results.length ? '다시 뽑기' : '랜덤 선택'}</button>
<p class="hint">각 악곡·난이도 조합의 선택 확률은 동일하며, 같은 결과가 다시 나올 수 있습니다.</p>
</section>
<p class="sr-only" role="status" aria-atomic="true">{announcement}</p>
<section class="result" aria-labelledby="results-title">
<div class="results-heading">
<h2 id="results-title">추첨 결과 <span>{results.length}개</span></h2>
<div class="result-actions">
<button class="clear-button" type="button" on:click={copyResults} disabled={results.length === 0 || copying}>{copying ? '복사 중…' : '목록 복사'}</button>
<button class="clear-button" type="button" on:click={clearResults} disabled={results.length === 0}>목록 초기화</button>
</div>
</div>
<p class="hint">먼저 뽑은 결과부터 표시합니다. 레벨 범위를 바꿔도 이전 결과는 유지됩니다.</p>
<p class="copy-status" role="status">{copyStatus}</p>
{#if showCopyText}
<label class="copy-text">복사용 텍스트
<textarea bind:this={copyTextArea} value={resultsText} readonly rows={Math.min(results.length + 1, 8)} on:focus={(event) => event.currentTarget.select()}></textarea>
</label>
{/if}
{#if results.length}
<ol class="results-list" aria-label="추첨 순서">
{#each results as result, index}
<li class:latest={index === results.length - 1}>
<span class="draw-number" aria-hidden="true">{index + 1}</span>
<strong class="song-title">{result.title}</strong>
<div class="badges"><span>{difficultyLabel(result.difficulty)}</span><strong>{result.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${result.difficultyLevel}`}</strong></div>
</li>
{/each}
</ol>
{:else}
<p class="placeholder">랜덤 선택을 누르면 악곡과 난이도를 함께 뽑습니다.</p>
{/if}
</section>
</main>
<style>
main { width: min(760px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
h1 { margin: 0 0 14px; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.04em; }
header > p:last-child { color: #a4adba; line-height: 1.7; }
.tools-nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
.tools-nav a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
.tools-nav a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
.tools-nav a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
.tools-nav a:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.tool-heading p { color: #a4adba; line-height: 1.7; margin-bottom: 0; }
.panel, .result { margin-top: 26px; padding: 26px; border: 1px solid #343c49; border-radius: 16px; background: #151a22; }
.toggle { display: flex; align-items: center; gap: 10px; font-weight: 800; }
.toggle input { width: 18px; height: 18px; accent-color: #ed8bb5; }
.range { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: end; margin-top: 22px; }
.range label { display: grid; gap: 8px; min-width: 0; color: #a4adba; font-size: 14px; }
.separator { padding-bottom: 12px; color: #a4adba; }
select { width: 100%; height: 44px; padding: 0 12px; border: 1px solid #414b5b; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; }
.hint { margin: 14px 0 0; color: #a4adba; font-size: 13px; line-height: 1.6; }
#candidate-status { margin: 24px 0 16px; color: #bec8d6; font-size: 14px; line-height: 1.6; }
#candidate-status.error { color: #f3a1a1; }
button { width: 100%; min-height: 48px; border: 0; border-radius: 10px; background: #ed8bb5; color: #21121a; font: inherit; font-weight: 850; cursor: pointer; }
button:hover:not(:disabled) { background: #f4a8c9; }
button:disabled { background: #343c49; color: #a4adba; cursor: not-allowed; }
button:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.result { border-color: #694254; background: rgb(237 139 181 / 6%); }
.results-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.result-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.copy-status { margin: 12px 0 0; color: #f3b4d0; font-size: 13px; line-height: 1.6; }
.copy-status:empty { margin: 0; }
.copy-text { display: grid; gap: 8px; margin-top: 12px; color: #a4adba; font-size: 13px; }
textarea { width: 100%; min-height: 88px; padding: 12px; resize: vertical; border: 1px solid #694254; border-radius: 8px; background: #101319; color: #e8ebf1; font: inherit; line-height: 1.7; }
h2 { margin: 0; font-size: 22px; }
h2 span { margin-left: 6px; color: #a4adba; font-size: 14px; font-weight: 500; }
.clear-button { width: auto; min-height: 38px; padding: 8px 12px; border: 1px solid #694254; background: transparent; color: #f3b4d0; font-size: 13px; }
.clear-button:hover:not(:disabled) { background: #302331; }
.clear-button:disabled { background: transparent; color: #87909e; border-color: #343c49; }
.results-list { margin: 20px 0 0; padding: 0; list-style: none; }
.results-list li { display: grid; grid-template-columns: minmax(28px, auto) minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 16px 12px; border-bottom: 1px solid #343c49; }
.results-list li.latest { border-radius: 9px; border-bottom-color: transparent; background: rgb(237 139 181 / 9%); }
.draw-number { color: #a4adba; font-size: 13px; font-variant-numeric: tabular-nums; }
.song-title { min-width: 0; overflow-wrap: anywhere; line-height: 1.6; }
.badges { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 13px; }
.badges span, .badges strong { padding: 6px 10px; border-radius: 8px; background: #302331; color: #f3b4d0; }
.placeholder { color: #a4adba; line-height: 1.7; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 520px) {
.panel, .result { padding: 20px; }
.results-list li { grid-template-columns: minmax(24px, auto) minmax(0, 1fr); gap: 8px; }
.badges { grid-column: 2; }
}
</style>