Files
HolodoriCalc/src/lib/components/PlayableChart.svelte
T
dal4segno 0ccf57d535
Publish GitHub Pages output / publish (push) Successful in 49s
feat: improve chart navigation and show original measure numbers
2026-10-04 23:05:57 +09:00

381 lines
30 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 { onMount, onDestroy, tick } from 'svelte';
import type { ExactChartNote, NoteScorePoint, SkillWindow } from '$lib/engine/types.ts';
import type { ChartVisual, ChartPosition } from '$lib/models/chart-visual.ts';
import { sampleHold } from '$lib/engine/chart-geometry.ts';
import { chartTimeAtPixel, chartPixelAtTime, chartDensity, chartColumnAtTime, chartColumnContains, chartMeasureAtTime } from '$lib/engine/chart-navigation.ts';
import { buildScorePoints, scorePointAtTime, formatTimelineScore, formatTimelineProbability } from '$lib/engine/score-timeline.ts';
export let songId: string;
export let difficulty: string;
export let durationSeconds: number;
export let notes: readonly NoteScorePoint[];
export let chartNotes: readonly ExactChartNote[] = [];
export let maximumScoreProbabilities: readonly number[] = [];
export let activeWindows: readonly SkillWindow[] = [];
export let specialWindows: readonly SkillWindow[] = [];
export let cardName: (id: string) => string;
export let memberColor: (id: string) => string;
const WIDTH = 480;
const LEFT = 64;
const LANE_WIDTH = 28;
const PAD = 24;
const SECONDS_PER_COLUMN = 6;
const TIME_SCALE = 180;
const GAP = 12;
let mounted = false;
let controller: AbortController | undefined;
let visual: ChartVisual | null = null;
let error = '';
let pixelsPerSecond = 90;
const zoomLevels = [45, 60, 90, 150, 240];
let hoverTime = 0;
let scroller: HTMLDivElement;
let pointer: { x: number; y: number; element: HTMLDivElement; index: number } | null = null;
let scrollLeft = 0;
let viewportWidth = 700;
let overviewDragging = false;
let lastPointerPosition: { x: number; y: number } | null = null;
let pointerMoved = false;
$: chartHeight = SECONDS_PER_COLUMN * TIME_SCALE + PAD * 2;
$: columnWidth = 200 * pixelsPerSecond / 90;
$: columnHeight = chartHeight * columnWidth / WIDTH;
$: points = buildScorePoints(notes, maximumScoreProbabilities);
$: selected = scorePointAtTime(points, hoverTime);
$: selectedMeasure = chartMeasureAtTime(visual?.measures ?? [], hoverTime);
$: density = chartDensity(chartNotes, durationSeconds);
$: visibleStart = Math.max(0, Math.min(durationSeconds, (scrollLeft - 10) / (columnWidth + GAP) * SECONDS_PER_COLUMN));
$: visibleEnd = Math.max(0, Math.min(durationSeconds, (scrollLeft + viewportWidth - 10) / (columnWidth + GAP) * SECONDS_PER_COLUMN));
$: previousSpecial = [...specialWindows].reverse().find(window => window.startSeconds < hoverTime - 1e-9);
$: nextSpecial = specialWindows.find(window => window.startSeconds > hoverTime + 1e-9);
$: holds = visual?.holds.map(sampleHold) ?? [];
$: columns = Array.from({ length: Math.max(1, Math.ceil(durationSeconds / SECONDS_PER_COLUMN)) }, (_, index) => {
const start = index * SECONDS_PER_COLUMN;
const end = start + SECONDS_PER_COLUMN;
return { index, start, end, firstMeasure: chartMeasureAtTime(visual?.measures ?? [], start), lastMeasure: chartMeasureAtTime(visual?.measures ?? [], Math.min(durationSeconds, end) - 1e-9),
measures: visual?.measures?.filter(measure => measure.timeSeconds >= start && measure.timeSeconds <= Math.min(durationSeconds, end)) ?? [],
notes: visual?.notes.filter(note => chartColumnContains(note.timeSeconds, start, end, durationSeconds)) ?? [],
holds: holds.filter(hold => hold.length > 1 && hold[0].timeSeconds < end && hold.at(-1)!.timeSeconds > start) };
});
// Match the scoring engine's special-start boundary and manual-judgement rules.
$: pushableSpecialStarts = [...new Set(specialWindows
.filter(window => chartNotes.some(note => note.type !== 'mid' && !note.isHoldEnd && Math.abs(note.timeSeconds - window.startSeconds) < 1e-9))
.map(window => window.startSeconds))];
$: rainbowId = `special-start-${songId}-${difficulty}`;
$: if (mounted) void loadVisual(songId, difficulty);
onMount(() => { mounted = true; });
onDestroy(() => controller?.abort());
async function loadVisual(id: string, level: string) {
controller?.abort();
const request = new AbortController();
controller = request;
visual = null;
error = '';
hoverTime = 0;
pointer = null;
try {
const response = await fetch(`/data/chart-visuals/${encodeURIComponent(id)}.${encodeURIComponent(level)}.json`, { signal: request.signal, cache: 'no-cache' });
if (!response.ok) throw new Error('채보를 불러오지 못했습니다.');
const data: ChartVisual = await response.json();
if (data.schemaVersion !== 1 || data.songId !== id || data.difficulty !== level || !Array.isArray(data.notes) || !Array.isArray(data.holds)) throw new Error('채보 데이터를 확인할 수 없습니다.');
if (data.measures && (!Array.isArray(data.measures) || data.measures.some((measure, index) => !Number.isInteger(measure.number) || !Number.isFinite(measure.timeSeconds) || (index > 0 && (measure.number !== data.measures![index - 1].number + 1 || measure.timeSeconds <= data.measures![index - 1].timeSeconds))))) throw new Error('마디 번호 데이터를 확인할 수 없습니다.');
if (request.signal.aborted) return;
visual = data;
await tick();
if (!request.signal.aborted) { scroller.scrollLeft = 0; scroller.scrollTop = scroller.scrollHeight; onScroll(); }
} catch (cause) {
if (!request.signal.aborted) error = cause instanceof Error ? cause.message : '채보를 불러오지 못했습니다.';
}
}
function x(lane: number): number { return LEFT + lane * LANE_WIDTH; }
function chartY(time: number, end: number): number { return PAD + (end - time) * TIME_SCALE; }
function holdPath(hold: ChartPosition[], end: number): string {
const vertices = [...hold.map(p => `${x(p.lane)},${chartY(p.timeSeconds, end)}`), ...[...hold].reverse().map(p => `${x(p.lane + p.width)},${chartY(p.timeSeconds, end)}`)];
return `M${vertices.join(' L')} Z`;
}
function formatTime(time: number): string {
return `${Math.floor(time / 60)}:${(time % 60).toFixed(3).padStart(6, '0')}`;
}
function updatePointer() {
if (!pointer) return;
const bounds = pointer.element.getBoundingClientRect();
const viewport = scroller.getBoundingClientRect();
if (pointer.x < Math.max(bounds.left, viewport.left) || pointer.x > Math.min(bounds.right, viewport.right) || pointer.y < Math.max(bounds.top, viewport.top) || pointer.y > Math.min(bounds.bottom, viewport.bottom)) return;
const column = columns[pointer.index];
if (!column) return;
const pixelY = (pointer.y - bounds.top) * chartHeight / bounds.height;
let time = Math.max(column.start, Math.min(durationSeconds, column.end, column.end - (pixelY - PAD) / TIME_SCALE));
const lane = ((pointer.x - bounds.left) * WIDTH / bounds.width - LEFT) / LANE_WIDTH;
// Hovering a note includes that judgement, even when its source time was rounded.
const nearest = column.notes.filter(n => lane >= n.lane && lane <= n.lane + n.width)
.reduce<ChartVisual['notes'][number] | null>((best, note) => Math.abs(note.timeSeconds - time) < Math.abs((best?.timeSeconds ?? Infinity) - time) ? note : best, null);
if (nearest && Math.abs(nearest.timeSeconds - time) * TIME_SCALE * bounds.height / chartHeight < 7) time = nearest.timeSeconds;
hoverTime = time;
}
function trackPointer(event: PointerEvent) {
pointerMoved = !lastPointerPosition || event.clientX !== lastPointerPosition.x || event.clientY !== lastPointerPosition.y;
lastPointerPosition = { x: event.clientX, y: event.clientY };
}
function onPointer(event: PointerEvent, index: number) {
// Moving the chart under a stationary pointer must not overwrite a seek.
if (event.type === 'pointermove' && !pointerMoved) return;
pointer = { x: event.clientX, y: event.clientY, element: event.currentTarget as HTMLDivElement, index };
updatePointer();
}
function clearHover() { pointer = null; }
function onScroll() { scrollLeft = scroller.scrollLeft; updatePointer(); }
function reveal(time: number) {
hoverTime = Math.max(0, Math.min(durationSeconds, time));
pointer = null;
const index = chartColumnAtTime(hoverTime, durationSeconds, SECONDS_PER_COLUMN);
scroller.scrollLeft = 10 + index * (columnWidth + GAP) + columnWidth / 2 - scroller.clientWidth / 2;
scroller.scrollTop = 30 + chartPixelAtTime(hoverTime, (index + 1) * SECONDS_PER_COLUMN, TIME_SCALE, columnWidth / WIDTH, PAD) - scroller.clientHeight / 2;
onScroll();
}
function onKeydown(event: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return;
event.preventDefault();
const step = event.key === 'ArrowRight' ? SECONDS_PER_COLUMN : event.key === 'ArrowLeft' ? -SECONDS_PER_COLUMN : event.key === 'PageUp' ? 5 : event.key === 'PageDown' ? -5 : event.key === 'ArrowUp' ? 0.1 : -0.1;
reveal(event.key === 'Home' ? 0 : event.key === 'End' ? durationSeconds : (hoverTime ?? 0) + step);
}
async function zoom(scale: number) {
const centerColumn = (scroller.scrollLeft + scroller.clientWidth / 2 - 10) / (columnWidth + GAP);
const index = Math.max(0, Math.min(columns.length - 1, Math.floor(centerColumn)));
const centerTime = chartTimeAtPixel(scroller.scrollTop + scroller.clientHeight / 2 - 30, columns[index].end, TIME_SCALE, columnWidth / WIDTH, PAD);
pointer = null;
pixelsPerSecond = scale;
await tick();
scroller.scrollLeft = 10 + centerColumn * (columnWidth + GAP) - scroller.clientWidth / 2;
scroller.scrollTop = 30 + chartPixelAtTime(centerTime, columns[index].end, TIME_SCALE, columnWidth / WIDTH, PAD) - scroller.clientHeight / 2;
onScroll();
}
function overviewPointer(event: PointerEvent) {
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect();
reveal((event.clientX - bounds.left) / Math.max(1, bounds.width) * durationSeconds);
}
function overviewDown(event: PointerEvent) {
if (event.button !== 0) return;
overviewDragging = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
overviewPointer(event);
}
function timePercent(time: number): number { return Math.max(0, Math.min(100, time / Math.max(0.001, durationSeconds) * 100)); }
function onWheel(event: WheelEvent) {
// At the fitted scale a mouse wheel pans across the folded chart.
// Keep native vertical scrolling when zoom makes the columns taller.
if (event.ctrlKey || event.metaKey || event.shiftKey || scroller.scrollHeight > scroller.clientHeight + 1 || event.deltaX !== 0) return;
if (scroller.scrollWidth <= scroller.clientWidth) return;
event.preventDefault();
pointer = null;
scroller.scrollLeft += event.deltaY * (event.deltaMode === 1 ? 20 : event.deltaMode === 2 ? scroller.clientWidth : 1);
onScroll();
}
</script>
<svelte:window on:pointermove|capture={trackPointer} on:pointerdown|capture={clearHover} />
<section class="playable-chart" aria-label="채보와 점수">
<div class="heading">
<div><h3>채보와 점수</h3><p>6초씩 나눈 채보를 왼쪽에서 오른쪽으로 이어 봅니다. 각 열은 아래에서 위로 읽습니다.</p></div>
{#if visual}
<div class="zoom-controls">
<button type="button" aria-label="채보 축소" disabled={pixelsPerSecond === zoomLevels[0]} on:click={() => zoom(zoomLevels[zoomLevels.indexOf(pixelsPerSecond) - 1])}>−</button>
<label>채보 확대 <select value={String(pixelsPerSecond)} on:change={(event) => zoom(Number(event.currentTarget.value))}><option value="45">50%</option><option value="60">67%</option><option value="90">100%</option><option value="150">167%</option><option value="240">267%</option></select></label>
<button type="button" aria-label="채보 확대" disabled={pixelsPerSecond === zoomLevels.at(-1)} on:click={() => zoom(zoomLevels[zoomLevels.indexOf(pixelsPerSecond) + 1])}>+</button>
</div>
{/if}
</div>
{#if error}
<p role="status">{error} <button type="button" on:click={() => loadVisual(songId, difficulty)}>다시 불러오기</button></p>
{:else if !visual}
<p role="status">채보를 불러오는 중입니다…</p>
{:else}
<div class="navigation">
<nav class="special-jumps" aria-label="스페셜 구간으로 이동">
<div class="special-heading">
<h4>스페셜 구간으로 이동</h4>
<div class="special-navigation"><button type="button" disabled={!previousSpecial} on:click={() => previousSpecial && reveal(previousSpecial.startSeconds)}>← 이전</button><button type="button" disabled={!nextSpecial} on:click={() => nextSpecial && reveal(nextSpecial.startSeconds)}>다음 →</button></div>
</div>
<div class="special-cards">
{#each specialWindows as window, index}
<button type="button" class:selected-special={Math.abs(hoverTime - window.startSeconds) < 1e-9} aria-pressed={Math.abs(hoverTime - window.startSeconds) < 1e-9} on:click={() => reveal(window.startSeconds)}>
<i style={`background: ${memberColor(window.cardId)}`}></i><span>{index + 1}. {cardName(window.cardId)}<small>{formatTime(window.startSeconds)}</small></span>
</button>
{/each}
</div>
</nav>
<div class="overview-heading"><strong>곡 전체 탐색</strong><span>밝은 상자: 현재 화면 · 막대: 노트 분포 · 아래 색상: 스페셜</span></div>
<div class="overview" role="slider" aria-label="곡 전체에서 이동할 시각" aria-orientation="horizontal" aria-valuemin="0" aria-valuemax={durationSeconds} aria-valuenow={hoverTime} aria-valuetext={formatTime(hoverTime)} tabindex="0"
on:pointerdown={overviewDown} on:pointermove={(event) => { if (overviewDragging) overviewPointer(event); }} on:pointerup={() => overviewDragging = false} on:pointercancel={() => overviewDragging = false} on:lostpointercapture={() => overviewDragging = false}
on:keydown={(event) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { event.preventDefault(); reveal(event.key === 'Home' ? 0 : event.key === 'End' ? durationSeconds : hoverTime + (event.key === 'ArrowRight' ? 1 : -1)); } }}>
<svg viewBox="0 0 120 52" preserveAspectRatio="none" aria-hidden="true">
{#each density as value, index}<rect class="density" x={index} y={36 - value * 30} width="0.8" height={value * 30} />{/each}
{#each specialWindows as window}<rect x={timePercent(window.startSeconds) * 1.2} y="43" width={Math.max(0, timePercent(window.endSeconds) - timePercent(window.startSeconds)) * 1.2} height="7" fill={memberColor(window.cardId)} />{/each}
</svg>
<div class="visible-range" style={`left: ${timePercent(visibleStart)}%; width: ${timePercent(visibleEnd) - timePercent(visibleStart)}%;`}></div>
<div class="overview-cursor" style={`left: ${timePercent(hoverTime)}%;`}></div>
</div>
<div class="overview-axis"><span>0:00</span><span>화면 {formatTime(visibleStart)} ~ {formatTime(visibleEnd)}</span><span>{formatTime(durationSeconds)}</span></div>
<div class="seek-controls">
<button type="button" on:click={() => reveal(0)}>시작</button>
<button type="button" aria-label="5초 이전으로 이동" on:click={() => reveal(hoverTime - 5)}>−5초</button>
<input type="range" aria-label="채보 시각 이동" min="0" max={durationSeconds} step="0.001" value={hoverTime} aria-valuetext={formatTime(hoverTime)} on:input={(event) => reveal(Number(event.currentTarget.value))} />
<button type="button" aria-label="5초 이후로 이동" on:click={() => reveal(hoverTime + 5)}>+5초</button>
<button type="button" on:click={() => reveal(durationSeconds)}>끝</button>
</div>
<div class="selected-score">
<strong>{formatTime(hoverTime)}</strong>
{#if selectedMeasure !== null}<span>마디 <b>#{selectedMeasure}</b></span>{/if}
<span>평균 <b class="average">{formatTimelineScore(selected.averageScore)}</b></span>
<span>최고 <b class="maximum">{formatTimelineScore(selected.maximumScore)}</b></span>
<span>이 시간까지 최고점 유지 확률 <b>{formatTimelineProbability(selected.maximumProbability)}</b></span>
<button type="button" on:click={() => reveal(hoverTime)}>선택 시각 다시 보기</button>
</div>
</div>
<div class="viewer-layout">
<div class="chart-panel">
<div class="chart-legend"><span class="tap-key">━ 탭</span><span class="hold-key">━ 롱노트</span><span class="flick-key">⌃ 플릭</span><span><i class="rainbow-key"></i> 밀어치기 가능</span><span># 원본 마디 번호</span><span>오른쪽: 액티브 / 스페셜</span></div>
<div class="viewport">
<div class="scroll" bind:this={scroller} bind:clientWidth={viewportWidth} on:scroll={onScroll} on:wheel|nonpassive={onWheel}
role="slider" aria-label="채보에서 점수를 확인할 시각" aria-orientation="horizontal" aria-valuemin="0" aria-valuemax={durationSeconds}
aria-valuenow={hoverTime ?? 0} aria-valuetext={selected && hoverTime !== null ? `${formatTime(hoverTime)}, 평균 점수 ${formatTimelineScore(selected.averageScore)}, 최고 점수 ${formatTimelineScore(selected.maximumScore)}, 이 시간까지 최고점 유지 확률 ${formatTimelineProbability(selected.maximumProbability)}` : '위아래 방향키로 시각을 선택하세요'}
tabindex="0" on:keydown={onKeydown} on:blur={clearHover}>
<div class="columns">
{#each columns as column}
{@const y = (time: number) => chartY(time, column.end)}
{@const gradientId = `${rainbowId}-${column.index}`}
<div class="chart-column" style={`width: ${columnWidth}px;`}>
<div class="column-heading"><span>{#if column.firstMeasure !== null && column.lastMeasure !== null}<b>#{column.firstMeasure} ~ #{column.lastMeasure}</b><small>{column.start} ~ {Math.min(durationSeconds, column.end)}초</small>{:else}{column.start} ~ {Math.min(durationSeconds, column.end)}초{/if}</span><small>A / SP</small></div>
<div class="surface" role="group" aria-label={`${column.start}초부터 ${Math.min(durationSeconds, column.end)}초 채보`} style={`height: ${columnHeight}px;`} on:pointermove={(event) => onPointer(event, column.index)} on:pointerdown={(event) => onPointer(event, column.index)} on:pointerleave={clearHover}>
<svg viewBox={`0 0 ${WIDTH} ${chartHeight}`} aria-hidden="true">
<defs>
<clipPath id={`clip-${gradientId}`}><rect x="0" y={PAD - 8} width={WIDTH} height={chartHeight - PAD * 2 + 16} /></clipPath>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1={LEFT} y1="0" x2={x(12)} y2="0">
<stop offset="0%" stop-color="#ff6685" /><stop offset="17%" stop-color="#ffac63" />
<stop offset="33%" stop-color="#ffe877" /><stop offset="50%" stop-color="#75edaa" />
<stop offset="67%" stop-color="#6ddcff" /><stop offset="83%" stop-color="#9990ff" />
<stop offset="100%" stop-color="#ee8dff" />
</linearGradient>
</defs>
<rect class="lane-background" x={LEFT} y="0" width={LANE_WIDTH * 12} height={chartHeight} />
{#each Array(13) as _, lane}<line class="lane-line" x1={x(lane)} x2={x(lane)} y1="0" y2={chartHeight} />{/each}
{#each Array.from({ length: SECONDS_PER_COLUMN + 1 }, (_, index) => column.start + index).filter(second => second <= durationSeconds) as second}
<line class="time-line" x1={LEFT} x2={WIDTH} y1={y(second)} y2={y(second)} />
{#if !visual.measures?.length}<text class="time-label" x={LEFT - 6} y={y(second) + 4} text-anchor="end">{second}초</text>{/if}
{/each}
{#each column.measures as measure}
<line class="measure-line" x1={LEFT} x2={WIDTH} y1={y(measure.timeSeconds)} y2={y(measure.timeSeconds)} />
<text class="measure-label" x={LEFT - 6} y={y(measure.timeSeconds) + 6} text-anchor="end">#{measure.number}</text>
{/each}
{#each activeWindows.filter(window => window.startSeconds < column.end && window.endSeconds > column.start) as window}<rect x="418" y={y(Math.min(durationSeconds, column.end, window.endSeconds))} width="20" height={Math.max(0, Math.min(durationSeconds, column.end, window.endSeconds) - Math.max(column.start, window.startSeconds)) * TIME_SCALE} fill={memberColor(window.cardId)} opacity="0.8" />{/each}
{#each specialWindows.filter(window => window.startSeconds < column.end && window.endSeconds > column.start) as window}<rect x="449" y={y(Math.min(durationSeconds, column.end, window.endSeconds))} width="20" height={Math.max(0, Math.min(durationSeconds, column.end, window.endSeconds) - Math.max(column.start, window.startSeconds)) * TIME_SCALE} fill={memberColor(window.cardId)} opacity="0.8" />{/each}
<g clip-path={`url(#clip-${gradientId})`}>
{#each column.holds as hold}<path class="hold-body" class:critical-hold={visual.notes.some(note => note.critical && note.timeSeconds === hold[0].timeSeconds && note.lane === hold[0].lane)} d={holdPath(hold, column.end)} />{/each}
{#each pushableSpecialStarts.filter(start => chartColumnContains(start, column.start, column.end, durationSeconds)) as start}
<g class="special-start" stroke={`url(#${gradientId})`}>
<line class="special-start-glow" x1={LEFT} x2={x(12)} y1={y(start)} y2={y(start)} />
<line x1={LEFT} x2={x(12)} y1={y(start)} y2={y(start)} />
</g>
{/each}
{#each column.notes as note}
<g class:critical={note.critical} class:flick={note.kind === 'flick' || note.direction} class:hold={note.kind === 'hold'} class:relay={note.kind === 'relay'} class="note">
<rect x={x(note.lane) + 1} y={y(note.timeSeconds) - (note.kind === 'relay' ? 2 : 4)} width={Math.max(1, note.width * LANE_WIDTH - 2)} height={note.kind === 'relay' ? 4 : 8} rx="2" />
{#if note.direction}<text x={x(note.lane + note.width / 2)} y={y(note.timeSeconds) - 6} text-anchor="middle">{note.direction === 'left' ? '↖' : note.direction === 'right' ? '↗' : '⌃'}</text>{/if}
</g>
{/each}
{#if chartColumnAtTime(hoverTime, durationSeconds, SECONDS_PER_COLUMN) === column.index}<line class="cursor" x1={LEFT} x2={WIDTH} y1={y(hoverTime)} y2={y(hoverTime)} />{/if}
</g>
</svg>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<div class="chart-help">
<p>채보 위에 마우스를 올리거나 터치하면 선택 시각과 누적 점수가 갱신됩니다. 탐색 막대를 드래그하거나 시각 슬라이더로 이동할 수 있습니다.</p>
<p>좌우 스와이프·가로 스크롤·Shift+휠로 열을 이동합니다. 세로 스크롤이 없는 배율에서는 휠로도 좌우 이동합니다. 키보드는 ← →로 6초, ↑ ↓로 0.1초, Page Up / Down으로 5초씩 이동합니다.</p>
<p>무지개 선은 스페셜 시작과 수동 판정 노트가 겹치는 밀어치기 가능 지점입니다. 롱노트 중간·끝의 자동 판정은 제외합니다.</p>
<p># 숫자는 원본 SUS의 마디 번호입니다. 밝은 가로선은 마디 경계이며 BPM·박자에 따라 간격이 달라집니다. 시간 간격은 초 기준이며 게임 내 스크롤 속도 변화는 반영하지 않습니다.</p>
</div>
</div>
{/if}
</section>
<style>
.playable-chart { margin: 20px 0; padding: 18px 0; border-top: 1px solid #2b313c; border-bottom: 1px solid #2b313c; }
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
h3 { margin: 0; font-size: 16px; color: #e8ebf1; }
p { color: #929dad; font-size: 12px; line-height: 1.65; }
.heading p { margin: 7px 0 0; }
label { flex-shrink: 0; color: #aab3c2; font-size: 12px; }
select, button { color: #dde3ed; background: #202631; border: 1px solid #3a4657; border-radius: 7px; padding: 8px; }
select { margin-left: 6px; }
button { cursor: pointer; }
button:hover { background: #303b4b; }
button:disabled { opacity: 0.4; cursor: default; }
button:focus-visible, select:focus-visible, input:focus-visible, .overview:focus-visible { outline: 2px solid #84d8ef; outline-offset: 2px; }
.zoom-controls { display: flex; align-items: center; gap: 7px; }
.zoom-controls button { min-width: 36px; font-size: 16px; }
.navigation { padding: 12px; margin-bottom: 14px; border: 1px solid #344052; border-radius: 10px; background: #151b25; }
.overview-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; color: #a4afbf; font-size: 11px; }
.overview-heading strong { color: #d8e2ee; font-size: 12px; flex-shrink: 0; }
.overview { position: relative; height: 64px; cursor: crosshair; touch-action: none; background: #0e141e; border: 1px solid #344052; border-radius: 6px; overflow: hidden; }
.density { fill: #6a9cbc; }
.visible-range { position: absolute; top: 0; bottom: 0; box-sizing: border-box; border: 2px solid #b1e6f5; background: rgb(135 210 235 / 12%); pointer-events: none; }
.overview-cursor { position: absolute; top: 0; bottom: 0; width: 2px; background: #f3aecb; transform: translateX(-1px); pointer-events: none; }
.overview-axis { display: flex; justify-content: space-between; gap: 8px; margin-top: 5px; color: #99a6b8; font-size: 10px; font-variant-numeric: tabular-nums; }
.seek-controls { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.seek-controls button { flex: none; font-size: 11px; min-height: 36px; }
.seek-controls input { flex: 1; min-width: 0; margin: 0; accent-color: #89d8ec; cursor: pointer; }
.selected-score { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 22px; margin-top: 12px; color: #a6b1c1; font-size: 11px; font-variant-numeric: tabular-nums; }
.selected-score strong { color: #fff; font-size: 13px; }
.selected-score span { display: flex; gap: 8px; }
.selected-score b { color: #e5ebf4; }.selected-score .average { color: #f08db8; }.selected-score .maximum { color: #64cbe4; }
.selected-score button { font-size: 11px; margin-left: auto; }
.viewer-layout { min-width: 0; }
.chart-panel { min-width: 0; border: 1px solid #344052; border-radius: 10px; overflow: hidden; background: #10141a; }
.chart-legend { display: flex; flex-wrap: wrap; gap: 10px; padding: 10px; color: #929dad; font-size: 11px; }
.tap-key { color: #73caff; }.hold-key { color: #6ee5ad; }.flick-key { color: #f294bd; }
.viewport { position: relative; }
.scroll { height: 540px; max-height: 70vh; overflow: auto; scrollbar-width: thin; overscroll-behavior: contain; outline-offset: -2px; }
.columns { display: flex; align-items: flex-start; gap: 12px; width: max-content; padding: 10px; }
.chart-column { flex: none; border-radius: 7px; overflow: hidden; background: #202735; }
.column-heading { height: 30px; padding: 0 9px; display: flex; align-items: center; justify-content: space-between; color: #bcc9da; font-size: 11px; }
.column-heading small { font-size: 9px; margin: 0; }
.surface { position: relative; width: 100%; }
svg { display: block; width: 100%; height: 100%; pointer-events: none; }
.lane-background { fill: #181e29; }.lane-line { stroke: #354052; stroke-width: 0.7; }
.time-line { stroke: #566174; stroke-width: 0.7; opacity: 0.5; }.time-label { fill: #bdcadc; font-size: 19px; }
.measure-line { stroke: #9eaec4; stroke-width: 1.8; }.measure-label { fill: #e7effb; font-size: 22px; font-weight: 700; }
.hold-body { fill: #53bf93; fill-opacity: 0.35; stroke: #66d3a6; stroke-opacity: 0.7; stroke-width: 0.8; }
.hold-body.critical-hold { fill: #e5c65b; stroke: #f4d070; }
.note rect { fill: #70c9ff; stroke: #c6edff; stroke-width: 0.7; }
.note.hold rect, .note.relay rect { fill: #6ce8ab; stroke: #c4ffe2; }
.note.flick rect { fill: #f184ba; stroke: #ffd4eb; }.note.critical rect { fill: #f4d070; stroke: #fff1b8; }
.note text { fill: #ffb3d5; font-size: 19px; font-weight: 800; }
.cursor { stroke: white; stroke-width: 1.5; stroke-dasharray: 5 3; }
.special-start { stroke-width: 3; }
.special-start-glow { stroke-width: 12; opacity: 0.22; }
.rainbow-key { display: inline-block; width: 18px; height: 3px; vertical-align: middle; background: linear-gradient(90deg, #ff6685, #ffac63, #ffe877, #75edaa, #6ddcff, #9990ff, #ee8dff); }
.special-jumps { margin-bottom: 16px; padding-bottom: 14px; border-bottom: 1px solid #344052; min-width: 0; }
.special-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
.special-heading h4 { margin: 0; font-size: 13px; color: #c3cbd8; }
.special-cards { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(150px, 1fr); gap: 8px; overflow-x: auto; padding: 3px; scrollbar-width: thin; }
.special-cards button { display: flex; gap: 8px; align-items: center; min-height: 60px; text-align: left; font-size: 12px; }
.special-cards button span { min-width: 0; overflow-wrap: anywhere; }
.special-navigation { display: flex; gap: 6px; }
.special-navigation button { font-size: 12px; min-height: 36px; }
.special-cards .selected-special { border-color: #8fcfe0; background: #263e4c; }
.special-cards i { width: 8px; height: 8px; border-radius: 50%; flex: none; }small { display: block; margin-top: 3px; color: #98a5b8; }
.chart-help { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; margin-top: 10px; }
.chart-help p { margin: 5px 0; }
@media (max-width: 700px) { .heading { align-items: flex-start; flex-direction: column; }.scroll { height: 480px; }.chart-help { grid-template-columns: minmax(0, 1fr); }.overview-heading { align-items: flex-start; flex-direction: column; }.overview-axis { font-size: 9px; }.seek-controls { gap: 4px; }.seek-controls button { padding: 7px; }.selected-score { gap: 8px 14px; }.special-heading { align-items: flex-start; flex-wrap: wrap; } }
</style>