381 lines
30 KiB
Svelte
381 lines
30 KiB
Svelte
<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>
|