feat: improve chart navigation and show original measure numbers
Publish GitHub Pages output / publish (push) Successful in 49s

This commit is contained in:
2026-10-04 23:05:57 +09:00
parent 4b8313c8a8
commit 0ccf57d535
849 changed files with 1236 additions and 928 deletions
+225 -79
View File
@@ -3,6 +3,7 @@
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;
@@ -17,27 +18,47 @@
export let memberColor: (id: string) => string;
const WIDTH = 480;
const LEFT = 48;
const LANE_WIDTH = 30;
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;
let hoverTime: number | null = null;
const zoomLevels = [45, 60, 90, 150, 240];
let hoverTime = 0;
let scroller: HTMLDivElement;
let surface: HTMLDivElement;
let surfaceWidth = WIDTH;
let pointer: { x: number; y: number } | null = null;
let tooltipTop = 16;
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 = Math.max(1, durationSeconds) * pixelsPerSecond + PAD * 2;
$: y = (time: number): number => PAD + (durationSeconds - time) * pixelsPerSecond;
$: chartHeight = SECONDS_PER_COLUMN * TIME_SCALE + PAD * 2;
$: columnWidth = 200 * pixelsPerSecond / 90;
$: columnHeight = chartHeight * columnWidth / WIDTH;
$: points = buildScorePoints(notes, maximumScoreProbabilities);
$: selected = hoverTime === null ? null : scorePointAtTime(points, hoverTime);
$: 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) ?? [];
$: seconds = Array.from({ length: Math.floor(durationSeconds) + 1 }, (_, i) => i);
$: 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))
@@ -54,74 +75,119 @@
controller = request;
visual = null;
error = '';
hoverTime = null;
hoverTime = 0;
pointer = null;
try {
const response = await fetch(`/data/chart-visuals/${encodeURIComponent(id)}.${encodeURIComponent(level)}.json`, { signal: request.signal });
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.scrollTop = scroller.scrollHeight;
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 holdPath(hold: ChartPosition[], _scale: number, _duration: number): string {
const vertices = [...hold.map(p => `${x(p.lane)},${y(p.timeSeconds)}`), ...[...hold].reverse().map(p => `${x(p.lane + p.width)},${y(p.timeSeconds)}`)];
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 || !surface) return;
const bounds = surface.getBoundingClientRect();
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(0, Math.min(durationSeconds, durationSeconds - (pixelY - PAD) / pixelsPerSecond));
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 = visual?.notes.filter(n => lane >= n.lane && lane <= n.lane + n.width)
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) * pixelsPerSecond < 7) time = nearest.timeSeconds;
if (nearest && Math.abs(nearest.timeSeconds - time) * TIME_SCALE * bounds.height / chartHeight < 7) time = nearest.timeSeconds;
hoverTime = time;
const viewport = scroller.getBoundingClientRect();
tooltipTop = Math.max(8, Math.min(viewport.height - 148, pointer.y - viewport.top + 18));
}
function onPointer(event: PointerEvent) { pointer = { x: event.clientX, y: event.clientY }; updatePointer(); }
function clearHover() { pointer = null; hoverTime = null; }
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 ratio = surfaceWidth / WIDTH;
scroller.scrollTop = y(hoverTime) * ratio - scroller.clientHeight / 2;
tooltipTop = 16;
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', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return;
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return;
event.preventDefault();
const step = event.key === 'PageUp' ? 5 : event.key === 'PageDown' ? -5 : event.key === 'ArrowUp' ? 0.1 : -0.1;
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(event: Event) {
const ratio = surfaceWidth / WIDTH;
const centerTime = durationSeconds - ((scroller.scrollTop + scroller.clientHeight / 2) / ratio - PAD) / pixelsPerSecond;
pixelsPerSecond = Number((event.currentTarget as HTMLSelectElement).value);
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.scrollTop = y(centerTime) * ratio - scroller.clientHeight / 2;
updatePointer();
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>아래에서 위로 시간이 흐릅니다. 채보 위에 마우스를 올리거나 터치하면 해당 시각의 누적 점수를 볼 수 있습니다.</p></div>
<div><h3>채보와 점수</h3><p>6초씩 나눈 채보를 왼쪽에서 오른쪽으로 이어 봅니다. 각 열은 아래에서 위로 읽습니다.</p></div>
{#if visual}
<label>시간 확대 <select value={String(pixelsPerSecond)} on:change={zoom}><option value="60">작게</option><option value="90">기본</option><option value="150">크게</option></select></label>
<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}
@@ -129,19 +195,67 @@
{: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></div>
<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} on:scroll={updatePointer}>
<div class="surface" bind:this={surface} bind:clientWidth={surfaceWidth} style={`height: ${chartHeight * surfaceWidth / WIDTH}px;`}
role="slider" aria-label="채보에서 점수를 확인할 시각" aria-orientation="vertical" aria-valuemin="0" aria-valuemax={durationSeconds}
<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:pointermove={onPointer} on:pointerdown={onPointer} on:pointerleave={(event) => { if (event.pointerType !== 'touch') clearHover(); }}
on:keydown={onKeydown} on:blur={clearHover}>
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>
<linearGradient id={rainbowId} gradientUnits="userSpaceOnUse" x1={LEFT} y1="0" x2={x(12)} y2="0">
<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" />
@@ -150,47 +264,46 @@
</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 seconds as second}
{#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)} />
<text class="time-label" x={LEFT - 6} y={y(second) + 4} text-anchor="end">{second}초</text>
{#if !visual.measures?.length}<text class="time-label" x={LEFT - 6} y={y(second) + 4} text-anchor="end">{second}초</text>{/if}
{/each}
{#each activeWindows as window}<rect x="418" y={y(Math.min(durationSeconds, window.endSeconds))} width="20" height={Math.max(0, Math.min(durationSeconds, window.endSeconds) - window.startSeconds) * pixelsPerSecond} fill={memberColor(window.cardId)} opacity="0.8" />{/each}
{#each specialWindows as window}<rect x="449" y={y(Math.min(durationSeconds, window.endSeconds))} width="20" height={Math.max(0, Math.min(durationSeconds, window.endSeconds) - window.startSeconds) * pixelsPerSecond} fill={memberColor(window.cardId)} opacity="0.8" />{/each}
{#each holds as hold}<path class="hold-body" d={holdPath(hold, pixelsPerSecond, durationSeconds)} />{/each}
{#each pushableSpecialStarts as start}
<g class="special-start" stroke={`url(#${rainbowId})`}>
{#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 visual.notes as note}
{#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 hoverTime !== null}<line class="cursor" x1={LEFT} x2={WIDTH} y1={y(hoverTime)} y2={y(hoverTime)} />{/if}
{#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>
{#if selected && hoverTime !== null}
<div class="hover-info" style={`top: ${tooltipTop}px;`}>
<strong>{formatTime(hoverTime)}</strong>
<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>
</div>
{/if}
</div>
</div>
<aside>
<h4>스페셜 구간으로 이동</h4>
{#each specialWindows as window, index}<button type="button" 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}
<button type="button" on:click={() => reveal(0)}>곡 시작으로 이동</button>
<p>휠·스크롤로 이동할 수 있습니다. 키보드는 채보에 포커스를 둔 뒤 ↑ ↓로 0.1초, Page Up / Down으로 5초씩 이동합니다.</p>
<div class="chart-help">
<p>채보 위에 마우스를 올리거나 터치하면 선택 시각과 누적 점수가 갱신됩니다. 탐색 막대를 드래그하거나 시각 슬라이더로 이동할 수 있습니다.</p>
<p>좌우 스와이프·가로 스크롤·Shift+휠로 열을 이동합니다. 세로 스크롤이 없는 배율에서는 휠로도 좌우 이동합니다. 키보드는 ← →로 6초, ↑ ↓로 0.1초, Page Up / Down으로 5초씩 이동합니다.</p>
<p>무지개 선은 스페셜 시작과 수동 판정 노트가 겹치는 밀어치기 가능 지점입니다. 롱노트 중간·끝의 자동 판정은 제외합니다.</p>
<p>노트의 위치·너비와 롱노트 연결을 표시합니다. 시간 간격은 초 기준이며 게임 내 스크롤 속도 변화는 반영하지 않습니다.</p>
</aside>
<p># 숫자는 원본 SUS의 마디 번호입니다. 밝은 가로선은 마디 경계이며 BPM·박자에 따라 간격이 달라집니다. 시간 간격은 초 기준이며 게임 내 스크롤 속도 변화는 반영하지 않습니다.</p>
</div>
</div>
{/if}
</section>
@@ -206,17 +319,43 @@
select { margin-left: 6px; }
button { cursor: pointer; }
button:hover { background: #303b4b; }
.viewer-layout { display: grid; grid-template-columns: minmax(0, 560px) minmax(160px, 1fr); gap: 20px; }
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: 560px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; }
.surface { position: relative; width: 100%; outline-offset: -2px; touch-action: pan-y; }
.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; }.time-label { fill: #97a4b8; font-size: 10px; }
.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; }
@@ -225,10 +364,17 @@
.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); }
.hover-info { position: absolute; left: 12px; z-index: 2; display: grid; gap: 6px; padding: 10px 12px; border: 1px solid #58647a; border-radius: 8px; color: #c6cedb; background: rgb(15 20 29 / 94%); box-shadow: 0 4px 18px #0008; font-size: 11px; pointer-events: none; max-width: calc(100% - 48px); }
.hover-info strong { color: white; }.hover-info span { display: flex; justify-content: space-between; gap: 14px; }.hover-info b { color: #ecf0f6; white-space: nowrap; }.hover-info .average { color: #f08db8; }.hover-info .maximum { color: #64cbe4; }
aside h4 { margin: 2px 0 12px; font-size: 13px; color: #c3cbd8; }
aside button { display: flex; gap: 8px; align-items: center; width: 100%; margin-bottom: 7px; text-align: left; font-size: 12px; }
aside i { width: 8px; height: 8px; border-radius: 50%; flex: none; }small { display: block; margin-top: 3px; color: #98a5b8; }
@media (max-width: 700px) { .heading { align-items: flex-start; flex-direction: column; }.viewer-layout { grid-template-columns: minmax(0, 1fr); }.scroll { height: 480px; }aside { display: flex; flex-wrap: wrap; gap: 6px; }aside h4, aside p { width: 100%; }aside button { width: auto; flex: 1 0 40%; } }
.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>