fix: refresh timeline axes when switching songs
Publish GitHub Pages output / publish (push) Successful in 43s

This commit is contained in:
2026-09-07 17:13:01 +09:00
parent 0af64052c6
commit 7d79e0ab97
3 changed files with 42 additions and 30 deletions
+4
View File
@@ -4,6 +4,10 @@ updatedAt: 2026-09-07T14:17:02+09:00
## 2026년 9월 7일 ## 2026년 9월 7일
### 그래프 시간축 갱신 수정
`FIXED` 다른 곡의 상세 배치를 열 때 이전 시간 눈금과 격자가 남던 문제를 수정했습니다. 곡 변경 시 시간축·점수축·커서를 갱신하고, 모바일에서도 점수 그래프와 스킬 구간의 너비를 맞춥니다.
### 모든 악곡 난이도 지원 ### 모든 악곡 난이도 지원
`NEW` 모든 악곡의 Easy·Normal 채보를 추가했습니다. 배치 분석과 조합 탐색에서 네 난이도를 선택할 수 있으며, 분석 기록 저장·복원과 리더 레이팅 곡 추천에도 모든 난이도를 반영합니다. `NEW` 모든 악곡의 Easy·Normal 채보를 추가했습니다. 배치 분석과 조합 탐색에서 네 난이도를 선택할 수 있으며, 분석 기록 저장·복원과 리더 레이팅 곡 추천에도 모든 난이도를 반영합니다.
+22 -14
View File
@@ -33,6 +33,14 @@
$: yTicks = Array.from({ length: chartMaximumScore / 500_000 + 1 }, (_, index) => index * 500_000); $: yTicks = Array.from({ length: chartMaximumScore / 500_000 + 1 }, (_, index) => index * 500_000);
$: hoveredPoint = hoverTime === null ? null : pointAtTime(points, hoverTime); $: 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[] { function buildPoints(source: readonly NoteScorePoint[]): ScorePoint[] {
let averageScore = 0; let averageScore = 0;
let maximumScore = 0; let maximumScore = 0;
@@ -45,27 +53,27 @@
return result; return result;
} }
function x(timeSeconds: number, width = viewWidth): number { function x(timeSeconds: number, width: number, duration: number): number {
return LEFT + Math.max(0, Math.min(1, timeSeconds / Math.max(1, durationSeconds))) * (width - LEFT - RIGHT); 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; return TOP + (1 - score / scaleMaximum) * PLOT_HEIGHT;
} }
function linePath(source: readonly ScorePoint[], field: 'averageScore' | 'maximumScore', duration: number, scaleMaximum: number, width: number): string { function linePath(source: readonly ScorePoint[], field: 'averageScore' | 'maximumScore', duration: number, scaleMaximum: number, width: number): string {
if (source.length === 0) return ''; 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) { for (let index = 1; index < source.length; index += 1) {
const previous = source[index - 1]; const previous = source[index - 1];
const point = source[index]; const point = source[index];
commands.push( commands.push(
`L ${x(point.timeSeconds, width).toFixed(2)} ${y(previous[field], scaleMaximum).toFixed(2)}`, `L ${x(point.timeSeconds, width, duration).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(point[field], scaleMaximum).toFixed(2)}`
); );
} }
const last = source.at(-1)!; 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(' '); return commands.join(' ');
} }
@@ -127,7 +135,7 @@
</small> </small>
</span> </span>
{#each yTicks as tick} {#each yTicks as tick}
<span class="axis-value" style={`top: ${y(tick)}px;`}>{formatAxisScore(tick)}</span> <span class="axis-value" style={`top: ${y(tick, chartMaximumScore)}px;`}>{formatAxisScore(tick)}</span>
{/each} {/each}
</strong> </strong>
<div <div
@@ -153,17 +161,17 @@
aria-label="시간에 따른 누적 평균 점수와 최고 점수 그래프" aria-label="시간에 따른 누적 평균 점수와 최고 점수 그래프"
> >
{#each timeTicks as tick} {#each timeTicks as tick}
<line class="time-grid-line" x1={x(tick, viewWidth)} x2={x(tick, viewWidth)} y1={TOP} y2={HEIGHT - BOTTOM} /> <line class="time-grid-line" x1={x(tick, viewWidth, durationSeconds)} x2={x(tick, viewWidth, durationSeconds)} y1={TOP} y2={HEIGHT - BOTTOM} />
{/each} {/each}
{#each yTicks as tick} {#each yTicks as tick}
<line class="grid-line" x1={LEFT} x2={viewWidth - RIGHT} y1={y(tick)} y2={y(tick)} /> <line class="grid-line" x1={LEFT} x2={viewWidth - RIGHT} y1={y(tick, chartMaximumScore)} y2={y(tick, chartMaximumScore)} />
{/each} {/each}
<path class="score-line maximum-line" d={maximumPath} /> <path class="score-line maximum-line" d={maximumPath} />
<path class="score-line average-line" d={averagePath} /> <path class="score-line average-line" d={averagePath} />
{#if hoveredPoint && hoverTime !== null} {#if hoveredPoint && hoverTime !== null}
<line class="cursor-line" x1={x(hoverTime, viewWidth)} x2={x(hoverTime, viewWidth)} y1={TOP} y2={HEIGHT - BOTTOM} /> <line class="cursor-line" x1={x(hoverTime, viewWidth, durationSeconds)} x2={x(hoverTime, viewWidth, durationSeconds)} y1={TOP} y2={HEIGHT - BOTTOM} />
<circle class="point maximum-point" cx={x(hoverTime, viewWidth)} cy={y(hoveredPoint.maximumScore)} r="5" /> <circle class="point maximum-point" cx={x(hoverTime, viewWidth, durationSeconds)} cy={y(hoveredPoint.maximumScore, chartMaximumScore)} r="5" />
<circle class="point average-point" cx={x(hoverTime, viewWidth)} cy={y(hoveredPoint.averageScore)} r="5" /> <circle class="point average-point" cx={x(hoverTime, viewWidth, durationSeconds)} cy={y(hoveredPoint.averageScore, chartMaximumScore)} r="5" />
{/if} {/if}
</svg> </svg>
@@ -184,7 +192,7 @@
.legend { display: flex; gap: 9px; color: #aab2bf; font-size: 10px; font-weight: 700; } .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; } .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; } .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 { display: inline-block; flex: none; width: 8px; height: 8px; border-radius: 50%; }
i.average { background: #f08db8; } i.average { background: #f08db8; }
i.maximum { background: #64cbe4; } i.maximum { background: #64cbe4; }
+16 -16
View File
@@ -842,16 +842,16 @@
return Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval); return Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval);
} }
function timelinePosition(seconds: number): number { function timelinePosition(seconds: number, duration: number): number {
return timelineDuration > 0 ? Math.max(0, Math.min(100, seconds / timelineDuration * 100)) : 0; return duration > 0 ? Math.max(0, Math.min(100, seconds / duration * 100)) : 0;
} }
function skillWindowStyle(startSeconds: number, endSeconds: number): string { function skillWindowStyle(startSeconds: number, endSeconds: number, duration: number): string {
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`; return `left: ${timelinePosition(startSeconds, duration)}%; width: ${Math.max(0, timelinePosition(endSeconds, duration) - timelinePosition(startSeconds, duration))}%;`;
} }
function timelineTrackStyle(): string { function timelineTrackStyle(width: number, duration: number): string {
return `min-width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`; return `min-width: ${width}px; --time-grid-step: ${timelinePosition(10, duration)}%;`;
} }
function memberColor(cardId: string): string { function memberColor(cardId: string): string {
@@ -859,8 +859,8 @@
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0]; return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
} }
function coloredWindowStyle(window: SkillWindow): string { function coloredWindowStyle(window: SkillWindow, duration: number): string {
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`; return `${skillWindowStyle(window.startSeconds, window.endSeconds, duration)} --member-color: ${memberColor(window.cardId)};`;
} }
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] { function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
@@ -1113,11 +1113,11 @@
{#each result.order as cardId, slot} {#each result.order as cardId, slot}
<div class="timeline-row member-row"> <div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong> <strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}> <div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window} {#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
<span <span
class="skill-window active-window" class="skill-window active-window"
style={coloredWindowStyle(window)} style={coloredWindowStyle(window, timelineDuration)}
title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
role="img" role="img"
aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`} aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}
@@ -1128,11 +1128,11 @@
{/each} {/each}
<div class="timeline-row summary-row"> <div class="timeline-row summary-row">
<strong class="lane-label summary-label">최종 액티브</strong> <strong class="lane-label summary-label">최종 액티브</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}> <div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each displayedEffectiveActiveWindows as window} {#each displayedEffectiveActiveWindows as window}
<span <span
class="skill-window effective-window" class="skill-window effective-window"
style={coloredWindowStyle(window)} style={coloredWindowStyle(window, timelineDuration)}
title={`${resultCardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`} title={`${resultCardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`}
role="img" role="img"
aria-label={`${resultCardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`} aria-label={`${resultCardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
@@ -1142,11 +1142,11 @@
</div> </div>
<div class="timeline-row summary-row"> <div class="timeline-row summary-row">
<strong class="lane-label summary-label">스페셜</strong> <strong class="lane-label summary-label">스페셜</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}> <div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each result.specialWindows as window} {#each result.specialWindows as window}
<span <span
class="skill-window special-window" class="skill-window special-window"
style={coloredWindowStyle(window)} style={coloredWindowStyle(window, timelineDuration)}
title={`${resultCardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} title={`${resultCardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
role="img" role="img"
aria-label={`${resultCardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`} aria-label={`${resultCardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
@@ -1156,8 +1156,8 @@
</div> </div>
<div class="timeline-row axis-row" aria-hidden="true"> <div class="timeline-row axis-row" aria-hidden="true">
<span class="lane-label axis-label"></span> <span class="lane-label axis-label"></span>
<div class="timeline-track time-axis" style={timelineTrackStyle()}> <div class="timeline-track time-axis" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each} {#each timelineTicks as second}<span style={`left: ${timelinePosition(second, timelineDuration)}%;`}>{second}초</span>{/each}
</div> </div>
</div> </div>
</div> </div>