From 7d79e0ab97ed7310830c8c6914e06056e4c92eea Mon Sep 17 00:00:00 2001 From: dal4segno Date: Mon, 7 Sep 2026 17:13:01 +0900 Subject: [PATCH] fix: refresh timeline axes when switching songs --- UPDATES.md | 4 +++ src/lib/components/ScoreTimelineChart.svelte | 36 ++++++++++++-------- src/routes/analysis/+page.svelte | 32 ++++++++--------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/UPDATES.md b/UPDATES.md index 8002b0f..5b5f212 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -4,6 +4,10 @@ updatedAt: 2026-09-07T14:17:02+09:00 ## 2026년 9월 7일 +### 그래프 시간축 갱신 수정 + +`FIXED` 다른 곡의 상세 배치를 열 때 이전 시간 눈금과 격자가 남던 문제를 수정했습니다. 곡 변경 시 시간축·점수축·커서를 갱신하고, 모바일에서도 점수 그래프와 스킬 구간의 너비를 맞춥니다. + ### 모든 악곡 난이도 지원 `NEW` 모든 악곡의 Easy·Normal 채보를 추가했습니다. 배치 분석과 조합 탐색에서 네 난이도를 선택할 수 있으며, 분석 기록 저장·복원과 리더 레이팅 곡 추천에도 모든 난이도를 반영합니다. diff --git a/src/lib/components/ScoreTimelineChart.svelte b/src/lib/components/ScoreTimelineChart.svelte index 0164de4..2497414 100644 --- a/src/lib/components/ScoreTimelineChart.svelte +++ b/src/lib/components/ScoreTimelineChart.svelte @@ -33,6 +33,14 @@ $: yTicks = Array.from({ length: chartMaximumScore / 500_000 + 1 }, (_, index) => index * 500_000); $: hoveredPoint = hoverTime === null ? null : pointAtTime(points, hoverTime); + // A different chart invalidates the previous pointer/keyboard selection. + $: resetHover(notes, durationSeconds); + + function resetHover(_notes: readonly NoteScorePoint[], _duration: number): void { + hoverTime = null; + tooltipPercent = 0; + } + function buildPoints(source: readonly NoteScorePoint[]): ScorePoint[] { let averageScore = 0; let maximumScore = 0; @@ -45,27 +53,27 @@ return result; } - function x(timeSeconds: number, width = viewWidth): number { - return LEFT + Math.max(0, Math.min(1, timeSeconds / Math.max(1, durationSeconds))) * (width - LEFT - RIGHT); + function x(timeSeconds: number, width: number, duration: number): number { + return LEFT + Math.max(0, Math.min(1, timeSeconds / Math.max(1, duration))) * (width - LEFT - RIGHT); } - function y(score: number, scaleMaximum = chartMaximumScore): number { + function y(score: number, scaleMaximum: number): number { return TOP + (1 - score / scaleMaximum) * PLOT_HEIGHT; } function linePath(source: readonly ScorePoint[], field: 'averageScore' | 'maximumScore', duration: number, scaleMaximum: number, width: number): string { if (source.length === 0) return ''; - const commands = [`M ${x(source[0].timeSeconds, width).toFixed(2)} ${y(source[0][field], scaleMaximum).toFixed(2)}`]; + const commands = [`M ${x(source[0].timeSeconds, width, duration).toFixed(2)} ${y(source[0][field], scaleMaximum).toFixed(2)}`]; for (let index = 1; index < source.length; index += 1) { const previous = source[index - 1]; const point = source[index]; commands.push( - `L ${x(point.timeSeconds, width).toFixed(2)} ${y(previous[field], scaleMaximum).toFixed(2)}`, - `L ${x(point.timeSeconds, width).toFixed(2)} ${y(point[field], scaleMaximum).toFixed(2)}` + `L ${x(point.timeSeconds, width, duration).toFixed(2)} ${y(previous[field], scaleMaximum).toFixed(2)}`, + `L ${x(point.timeSeconds, width, duration).toFixed(2)} ${y(point[field], scaleMaximum).toFixed(2)}` ); } const last = source.at(-1)!; - commands.push(`L ${x(duration, width).toFixed(2)} ${y(last[field], scaleMaximum).toFixed(2)}`); + commands.push(`L ${x(duration, width, duration).toFixed(2)} ${y(last[field], scaleMaximum).toFixed(2)}`); return commands.join(' '); } @@ -127,7 +135,7 @@ {#each yTicks as tick} - {formatAxisScore(tick)} + {formatAxisScore(tick)} {/each}
{#each timeTicks as tick} - + {/each} {#each yTicks as tick} - + {/each} {#if hoveredPoint && hoverTime !== null} - - - + + + {/if} @@ -184,7 +192,7 @@ .legend { display: flex; gap: 9px; color: #aab2bf; font-size: 10px; font-weight: 700; } .legend span, .tooltip span { display: flex; align-items: center; gap: 6px; white-space: nowrap; } .axis-value { position: absolute; right: 7px; color: #747f90; font-size: 10px; font-weight: 650; transform: translateY(-50%); white-space: nowrap; } - .score-track { position: relative; flex: 1 0 auto; height: 220px; overflow: hidden; border-bottom: 1px solid #465062; background: #10141a; } + .score-track { position: relative; flex: 1 0 0; height: 220px; overflow: hidden; border-bottom: 1px solid #465062; background: #10141a; } i { display: inline-block; flex: none; width: 8px; height: 8px; border-radius: 50%; } i.average { background: #f08db8; } i.maximum { background: #64cbe4; } diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index f667e48..447c7c2 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -842,16 +842,16 @@ return Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval); } - function timelinePosition(seconds: number): number { - return timelineDuration > 0 ? Math.max(0, Math.min(100, seconds / timelineDuration * 100)) : 0; + function timelinePosition(seconds: number, duration: number): number { + return duration > 0 ? Math.max(0, Math.min(100, seconds / duration * 100)) : 0; } - function skillWindowStyle(startSeconds: number, endSeconds: number): string { - return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`; + function skillWindowStyle(startSeconds: number, endSeconds: number, duration: number): string { + return `left: ${timelinePosition(startSeconds, duration)}%; width: ${Math.max(0, timelinePosition(endSeconds, duration) - timelinePosition(startSeconds, duration))}%;`; } - function timelineTrackStyle(): string { - return `min-width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`; + function timelineTrackStyle(width: number, duration: number): string { + return `min-width: ${width}px; --time-grid-step: ${timelinePosition(10, duration)}%;`; } function memberColor(cardId: string): string { @@ -859,8 +859,8 @@ return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0]; } - function coloredWindowStyle(window: SkillWindow): string { - return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`; + function coloredWindowStyle(window: SkillWindow, duration: number): string { + return `${skillWindowStyle(window.startSeconds, window.endSeconds, duration)} --member-color: ${memberColor(window.cardId)};`; } function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] { @@ -1113,11 +1113,11 @@ {#each result.order as cardId, slot}
{slot + 1}{resultCardName(cardId)} -
+
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window} 최종 액티브 -
+
{#each displayedEffectiveActiveWindows as window} 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`} role="img" aria-label={`${resultCardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`} @@ -1142,11 +1142,11 @@
스페셜 -
+
{#each result.specialWindows as window}