feat: improve chart navigation and show original measure numbers
Publish GitHub Pages output / publish (push) Successful in 49s
Publish GitHub Pages output / publish (push) Successful in 49s
This commit is contained in:
+10
@@ -4,6 +4,16 @@ updatedAt: 2026-10-04T00:00:00.000Z
|
||||
|
||||
## 2026년 10월 4일
|
||||
|
||||
### 배치 분석 채보 탐색 개선
|
||||
|
||||
`IMPROVED` 채보를 6초 단위 열로 나눠 가로로 이어 보는 형태로 개선했습니다. 곡 전체 탐색 막대의 클릭·드래그, 시각 슬라이더, 5초 이동, 이전·다음 스페셜 버튼으로 바로 이동할 수 있습니다. 확대를 5단계로 늘리고 확대·축소 시 보고 있던 구간을 유지합니다. 선택 시각의 점수를 채보 위 고정 영역에 표시해 노트를 가리지 않고 확인할 수 있습니다.
|
||||
|
||||
`IMPROVED` 채보를 점수·스킬 그래프와 스페셜 노트 요약 아래로 이동했습니다. 액티브 토글을 조작하면서 최고점 변화와 점수 그래프를 가까이에서 확인할 수 있습니다.
|
||||
|
||||
`IMPROVED` 스페셜 구간 이동 버튼을 곡 전체 탐색 위에 가로로 배치하고 채보 표시 영역을 넓혔습니다. 좁은 화면에서는 스페셜 버튼을 좌우로 밀어 확인할 수 있습니다.
|
||||
|
||||
`IMPROVED` 원본 SUS의 마디 번호와 경계를 채보에 표시합니다. BPM·박자 변화를 반영하며 각 열의 마디 범위와 선택 시각의 마디 번호도 함께 확인할 수 있습니다.
|
||||
|
||||
### 이벤트 과제곡 점수 계산 수정
|
||||
|
||||
`FIX` 이벤트 과제곡 보너스를 가창 보너스와 더해 노트마다 적용하던 문제를 수정했습니다. 가창 보너스는 노트별 점수에 적용하고, 이벤트 과제곡 보너스는 노트 점수를 합산한 최종 점수에 곱합니다. 이벤트 최고점·평균 점수·PT 비교에 같은 계산식을 적용합니다.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 배치 분석 채보 시각화
|
||||
|
||||
`PlayableChart.svelte`는 점수 결과와 별도로 `static/data/chart-visuals/`의 레인·너비·플릭·롱노트 정보를 지연 로드한다. 계산기의 노트 목록과 점수 공식을 바꾸지 않는다. 그래프와 채보는 `score-timeline.ts`의 누적 합산·시각 검색·숫자 표시를 공유한다.
|
||||
|
||||
## 데이터 갱신
|
||||
|
||||
1. 저장소 지침에 따라 `npm run data:sync`의 전체 보고서를 검토하고 사용자에게 설명한다.
|
||||
2. 점수 채보 수집이 필요하면 기존 수집 절차를 완료한다.
|
||||
3. `npm run data:collect-chart-visuals`를 실행한다. 검토한 게임 DB 스냅샷을 검증하고 로컬의 공식 게임 SUS 자산을 읽는다.
|
||||
4. 원본 SUS의 SHA-256과 노트 시각이 점수 채보와 같을 때만 추출한다. 일치하지 않으면 중단하며, 같은 게임 SUS로 점수 채보와 시각화를 먼저 다시 생성한다.
|
||||
|
||||
현재 12개 레인, 탭·플릭 방향·크리티컬 표시, 롱노트 앵커·숨은 베지어 제어점을 보존한다. 자동 중간 점수 판정을 탭으로 그리지 않는다. 세로축은 실제 초 단위로, 게임의 스크롤 속도 변화를 재현하지 않는다.
|
||||
|
||||
## 채보 탐색
|
||||
|
||||
채보는 6초 단위 열을 왼쪽에서 오른쪽으로 이어 표시하며 각 열은 아래에서 위로 읽는다. 열 경계의 노트는 다음 열에 한 번만 표시하고, 곡 마지막 시각은 마지막 열에 포함한다. 롱노트 곡선을 먼저 샘플링한 뒤 각 열에서 잘라 표시해 연결을 보존한다. 확대·축소는 가로 화면 중심과 세로 중심 시각을 유지한다.
|
||||
|
||||
시각화 JSON의 `measures`는 같은 해시의 SUS에서 추출한 마디 번호와 시작 시각이다. `#000`은 `#0`으로 표시하며 빈 마디도 생략하지 않는다. `MEASURE_COUNT`, 지속되는 마디 길이 지시문, 마디 중간 BPM 변경과 `WAVEOFFSET`을 반영한다. 왼쪽 번호와 밝은 가로선, 열 머리의 마디 범위, 선택 시각의 마디 표시가 이 정보를 공유한다. 6초 단위 열 번호를 마디 번호로 사용하지 않는다. 이전 JSON에 마디 정보가 없으면 초 눈금만 표시한다.
|
||||
|
||||
전체 탐색 막대는 수동·자동 판정을 포함한 점수 채보의 노트 분포, 스페셜 적용 구간과 현재 가로 화면 범위를 보여 준다. 클릭·드래그, 시각 슬라이더, 5초 이동, 이전·다음 스페셜과 키보드로 선택 시각을 바꾸고 해당 열로 이동한다. 선택 시각과 누적 점수는 채보 위 고정 영역에 남는다. `chart-navigation.ts`는 반응형 좌표와 열 경계를 담당한다.
|
||||
|
||||
가로로 구간을 이어 보는 표시 방식은 [holodori-scores](https://github.com/HolodoriDB/holodori-scores)를 참고했다. Python 렌더러나 이미지 자산을 포함하지 않으며, 기존 공식 SUS 기반 JSON과 브라우저 SVG를 사용한다.
|
||||
|
||||
Pages 빌드에서는 점수 채보만 번들에 포함하고 시각화 JSON은 `data/chart-visuals/`에 남겨 개별 요청으로 제공한다. `finalize-pages-build.mjs`에서 이 디렉터리를 제거하지 않아야 한다.
|
||||
@@ -103,6 +103,8 @@ export function parseSusTimeline(text) {
|
||||
bpmEvents.sort((left, right) => left.beat - right.beat);
|
||||
const uniqueBpmEvents = bpmEvents.filter((event, index) => index === 0 || event.beat !== bpmEvents[index - 1].beat);
|
||||
function secondsAt(beat) {
|
||||
const waveOffset = Number(headers.get('WAVEOFFSET') ?? 0);
|
||||
if (beat < uniqueBpmEvents[0].beat) return beat * 60 / Number(headers.get('BASEBPM') ?? 120) + waveOffset;
|
||||
let seconds = 0;
|
||||
let previous = uniqueBpmEvents[0];
|
||||
if (previous.beat > 0) seconds += previous.beat * 60 / Number(headers.get('BASEBPM') ?? 120);
|
||||
@@ -112,7 +114,7 @@ export function parseSusTimeline(text) {
|
||||
previous = event;
|
||||
}
|
||||
seconds += (beat - previous.beat) * 60 / previous.bpm;
|
||||
return seconds + Number(headers.get('WAVEOFFSET') ?? 0);
|
||||
return seconds + waveOffset;
|
||||
}
|
||||
const key = (note) => `${note.beat}|${note.lane}`;
|
||||
const tapKeys = new Set(taps.filter((note) => note.kind === 1 || note.kind === 2).map(key));
|
||||
@@ -190,6 +192,12 @@ export function parseSusTimeline(text) {
|
||||
visualNotes.push(...visibleRelays.map(note => visualNote(note, 'relay')));
|
||||
const visualHolds = holds.map(hold => hold.filter((note, i) => i === 0 || i === hold.length - 1 || !attached.has(key(note)))
|
||||
.map(note => ({ ...point(note), ...(note.kind === 4 ? { control: true } : {}) })));
|
||||
const lastSourceMeasure = Math.max(0, ...lines.map(line => Number(line.match(/^#(\d{3})\w+:/)?.[1] ?? 0)));
|
||||
const measureCount = Number(headers.get('MEASURE_COUNT') ?? lastSourceMeasure + 1);
|
||||
if (!Number.isInteger(measureCount) || measureCount < 1 || measureCount >= starts.length) throw new Error('잘못된 SUS 마디 수입니다.');
|
||||
// Keep the SUS's zero-based numbers, including silent bars and the end boundary.
|
||||
const measures = Array.from({ length: measureCount + 1 }, (_, number) => ({ number, timeSeconds: secondsAt(starts[number]) }));
|
||||
if (measures.some((measure, index) => !Number.isFinite(measure.timeSeconds) || (index > 0 && measure.timeSeconds <= measures[index - 1].timeSeconds))) throw new Error('잘못된 SUS 마디 시각입니다.');
|
||||
return { songId: headers.get('MUSIC_ID'), notes, specialStartSeconds: specialStarts.sort((left, right) => left - right).map(secondsAt),
|
||||
visual: { laneCount: 12, notes: visualNotes.sort((a, b) => a.timeSeconds - b.timeSeconds), holds: visualHolds } };
|
||||
visual: { laneCount: 12, notes: visualNotes.sort((a, b) => a.timeSeconds - b.timeSeconds), holds: visualHolds, measures } };
|
||||
}
|
||||
|
||||
@@ -76,4 +76,38 @@ test('마디 길이 변경은 다음 변경까지 유지한다', () => {
|
||||
#00212:11
|
||||
`;
|
||||
assert.deepEqual(parseSusTimeline(text).notes.map(n=>n.timeSeconds),[3,6]);
|
||||
assert.deepEqual(parseSusTimeline(text).visual.measures, [
|
||||
{ number: 0, timeSeconds: 0 }, { number: 1, timeSeconds: 3 },
|
||||
{ number: 2, timeSeconds: 6 }, { number: 3, timeSeconds: 9 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('원본 마디 번호는 빈 마디, 마디 중간 BPM 변경과 WAVEOFFSET을 반영한다', () => {
|
||||
const text = `#MUSIC_ID m0003
|
||||
#MEASURE_COUNT 4
|
||||
#BASEBPM 60
|
||||
#WAVEOFFSET 0.5
|
||||
#NORMAL_NOTE_COUNT 1
|
||||
#FLICK_NOTE_COUNT 0
|
||||
#LONG_START_NOTE_COUNT 0
|
||||
#LONG_END_NOTE_COUNT 0
|
||||
#LONG_FLICK_END_NOTE_COUNT 0
|
||||
#LONG_RELAY_NOTE_COUNT 0
|
||||
#LONG_CONTINUE_NOTE_COUNT 0
|
||||
#BPM01:60
|
||||
#BPM02:120
|
||||
#00008:01
|
||||
#00108:0002
|
||||
#00202:3
|
||||
#00312:11
|
||||
`;
|
||||
assert.deepEqual(parseSusTimeline(text).visual.measures, [
|
||||
{ number: 0, timeSeconds: 0.5 }, { number: 1, timeSeconds: 4.5 },
|
||||
{ number: 2, timeSeconds: 7.5 }, { number: 3, timeSeconds: 9 },
|
||||
{ number: 4, timeSeconds: 10.5 }
|
||||
]);
|
||||
assert.deepEqual(parseSusTimeline(text).notes.map(note => note.timeSeconds), [9]);
|
||||
// Before the first explicit BPM event, use BASEBPM rather than extrapolating it backwards.
|
||||
assert.deepEqual(parseSusTimeline(text.replace('#00008:01\n', '')).visual.measures, parseSusTimeline(text).visual.measures);
|
||||
assert.throws(() => parseSusTimeline(text.replace('#MEASURE_COUNT 4', '#MEASURE_COUNT NaN')), /마디 수/);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
{#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}
|
||||
{/each}
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chartTimeAtPixel, chartPixelAtTime, chartDensity, chartColumnAtTime, chartColumnContains, chartMeasureAtTime } from './chart-navigation.ts';
|
||||
|
||||
test('responsive chart coordinates preserve time across zoom levels and clamp padding', () => {
|
||||
for (const ratio of [0.55, 1, 1.2]) {
|
||||
for (const scale of [45, 90, 240]) {
|
||||
const pixel = chartPixelAtTime(31.125, 100, scale, ratio, 24);
|
||||
assert.ok(Math.abs(chartTimeAtPixel(pixel, 100, scale, ratio, 24) - 31.125) < 1e-9);
|
||||
assert.equal(chartTimeAtPixel(0, 100, scale, ratio, 24), 100);
|
||||
assert.equal(chartTimeAtPixel((100 * scale + 48) * ratio, 100, scale, ratio, 24), 0);
|
||||
// Keeping this time at the viewport center after zoom must round-trip.
|
||||
const scroll = chartPixelAtTime(31.125, 100, scale, ratio, 24) - 240;
|
||||
assert.ok(Math.abs(chartTimeAtPixel(scroll + 240, 100, scale, ratio, 24) - 31.125) < 1e-9);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('folded columns render boundaries once, including the exact song ending', () => {
|
||||
const duration = 12;
|
||||
assert.equal(chartColumnAtTime(6, duration, 6), 1);
|
||||
assert.equal(chartColumnAtTime(12, duration, 6), 1);
|
||||
assert.equal(chartColumnAtTime(-1, duration, 6), 0);
|
||||
assert.equal(chartColumnContains(6, 0, 6, duration), false);
|
||||
assert.equal(chartColumnContains(6, 6, 12, duration), true);
|
||||
assert.equal(chartColumnContains(12, 6, 12, duration), true);
|
||||
assert.equal(chartColumnContains(11.8, 6, 12, 11.8), true);
|
||||
});
|
||||
|
||||
test('measure lookup follows actual boundaries, including BPM changes and offsets', () => {
|
||||
const measures = [{ number: 0, timeSeconds: 0.5 }, { number: 1, timeSeconds: 4.5 }, { number: 2, timeSeconds: 7.5 }];
|
||||
assert.equal(chartMeasureAtTime(measures, 0), null);
|
||||
assert.equal(chartMeasureAtTime(measures, 4.499999), 0);
|
||||
assert.equal(chartMeasureAtTime(measures, 4.5), 1);
|
||||
assert.equal(chartMeasureAtTime(measures, 7.5), 2);
|
||||
assert.equal(chartMeasureAtTime([], 100), null);
|
||||
});
|
||||
|
||||
test('overview includes simultaneous notes and chart endpoints without NaN for empty charts', () => {
|
||||
assert.deepEqual(chartDensity([{ timeSeconds: 0 }, { timeSeconds: 0 }, { timeSeconds: 5 }, { timeSeconds: 10 }], 10, 2), [1, 1]);
|
||||
assert.deepEqual(chartDensity([], 0, 3), [0, 0, 0]);
|
||||
assert.deepEqual(chartDensity([{ timeSeconds: 0 }], 0, 3), [1, 0, 0]);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Geometry in rendered pixels, including the responsive SVG width. */
|
||||
export function chartTimeAtPixel(pixel: number, duration: number, scale: number, ratio: number, padding: number): number {
|
||||
return Math.max(0, Math.min(duration, duration - (pixel / Math.max(ratio, 0.001) - padding) / scale));
|
||||
}
|
||||
|
||||
export function chartPixelAtTime(time: number, duration: number, scale: number, ratio: number, padding: number): number {
|
||||
return (padding + (duration - Math.max(0, Math.min(duration, time))) * scale) * ratio;
|
||||
}
|
||||
|
||||
/** Fixed-size overview; the final judgement belongs to the last bin. */
|
||||
export function chartDensity(notes: readonly { timeSeconds: number }[], duration: number, bins = 120): number[] {
|
||||
const counts = Array<number>(bins).fill(0);
|
||||
for (const note of notes) {
|
||||
const index = Math.max(0, Math.min(bins - 1, Math.floor(note.timeSeconds / Math.max(duration, 0.001) * bins)));
|
||||
counts[index]++;
|
||||
}
|
||||
const maximum = Math.max(1, ...counts);
|
||||
return counts.map(count => count / maximum);
|
||||
}
|
||||
|
||||
export function chartColumnAtTime(time: number, duration: number, secondsPerColumn: number): number {
|
||||
const count = Math.max(1, Math.ceil(duration / secondsPerColumn));
|
||||
return Math.max(0, Math.min(count - 1, Math.floor(time / secondsPerColumn)));
|
||||
}
|
||||
|
||||
export function chartColumnContains(time: number, start: number, end: number, duration: number): boolean {
|
||||
return time >= start && (time < end || (time === duration && end >= duration));
|
||||
}
|
||||
|
||||
/** Use actual SUS boundaries; silent bars and BPM changes still count. */
|
||||
export function chartMeasureAtTime(measures: readonly { number: number; timeSeconds: number }[], time: number): number | null {
|
||||
if (measures.length === 0 || time < measures[0].timeSeconds) return null;
|
||||
let low = 0;
|
||||
let high = measures.length - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.ceil((low + high) / 2);
|
||||
if (measures[middle].timeSeconds <= time) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
return measures[low].number;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ChartPosition = { timeSeconds: number; lane: number; width: number; control?: boolean };
|
||||
export type ChartMeasure = { number: number; timeSeconds: number };
|
||||
export type ChartVisualNote = ChartPosition & {
|
||||
kind: 'tap' | 'flick' | 'hold' | 'relay';
|
||||
direction?: 'up' | 'left' | 'right';
|
||||
@@ -12,4 +13,5 @@ export type ChartVisual = {
|
||||
laneCount: number;
|
||||
notes: ChartVisualNote[];
|
||||
holds: ChartPosition[][];
|
||||
measures?: ChartMeasure[];
|
||||
};
|
||||
|
||||
@@ -1310,13 +1310,6 @@
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
{#if simulationContext}
|
||||
<PlayableChart songId={simulationContext.chart.songId} difficulty={simulationContext.chart.difficulty}
|
||||
chartNotes={simulationContext.chart.notes}
|
||||
durationSeconds={timelineDuration} notes={displayedResult!.notes} {maximumScoreProbabilities}
|
||||
activeWindows={displayedEffectiveActiveWindows} specialWindows={result.specialWindows}
|
||||
cardName={resultCardName} {memberColor} />
|
||||
{/if}
|
||||
<p class="board-guide">그래프를 좌우로 밀면 전체 시간대를 확인할 수 있습니다.</p>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users must be able to scroll the timeline.) -->
|
||||
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프" tabindex="0">
|
||||
@@ -1381,6 +1374,13 @@
|
||||
</div>
|
||||
</section>
|
||||
<SpecialNoteSummary {result} cardName={resultCardName} />
|
||||
{#if simulationContext}
|
||||
<PlayableChart songId={simulationContext.chart.songId} difficulty={simulationContext.chart.difficulty}
|
||||
chartNotes={simulationContext.chart.notes}
|
||||
durationSeconds={timelineDuration} notes={displayedResult!.notes} {maximumScoreProbabilities}
|
||||
activeWindows={displayedEffectiveActiveWindows} specialWindows={result.specialWindows}
|
||||
cardName={resultCardName} {memberColor} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
|
||||
{/if}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user