fix: refresh timeline axes when switching songs
Publish GitHub Pages output / publish (push) Successful in 43s
Publish GitHub Pages output / publish (push) Successful in 43s
This commit is contained in:
@@ -4,6 +4,10 @@ updatedAt: 2026-09-07T14:17:02+09:00
|
||||
|
||||
## 2026년 9월 7일
|
||||
|
||||
### 그래프 시간축 갱신 수정
|
||||
|
||||
`FIXED` 다른 곡의 상세 배치를 열 때 이전 시간 눈금과 격자가 남던 문제를 수정했습니다. 곡 변경 시 시간축·점수축·커서를 갱신하고, 모바일에서도 점수 그래프와 스킬 구간의 너비를 맞춥니다.
|
||||
|
||||
### 모든 악곡 난이도 지원
|
||||
|
||||
`NEW` 모든 악곡의 Easy·Normal 채보를 추가했습니다. 배치 분석과 조합 탐색에서 네 난이도를 선택할 수 있으며, 분석 기록 저장·복원과 리더 레이팅 곡 추천에도 모든 난이도를 반영합니다.
|
||||
|
||||
@@ -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 @@
|
||||
</small>
|
||||
</span>
|
||||
{#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}
|
||||
</strong>
|
||||
<div
|
||||
@@ -153,17 +161,17 @@
|
||||
aria-label="시간에 따른 누적 평균 점수와 최고 점수 그래프"
|
||||
>
|
||||
{#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 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}
|
||||
<path class="score-line maximum-line" d={maximumPath} />
|
||||
<path class="score-line average-line" d={averagePath} />
|
||||
{#if hoveredPoint && hoverTime !== null}
|
||||
<line class="cursor-line" x1={x(hoverTime, viewWidth)} x2={x(hoverTime, viewWidth)} y1={TOP} y2={HEIGHT - BOTTOM} />
|
||||
<circle class="point maximum-point" cx={x(hoverTime, viewWidth)} cy={y(hoveredPoint.maximumScore)} r="5" />
|
||||
<circle class="point average-point" cx={x(hoverTime, viewWidth)} cy={y(hoveredPoint.averageScore)} r="5" />
|
||||
<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, durationSeconds)} cy={y(hoveredPoint.maximumScore, chartMaximumScore)} r="5" />
|
||||
<circle class="point average-point" cx={x(hoverTime, viewWidth, durationSeconds)} cy={y(hoveredPoint.averageScore, chartMaximumScore)} r="5" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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}
|
||||
<div class="timeline-row member-row">
|
||||
<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}
|
||||
<span
|
||||
class="skill-window active-window"
|
||||
style={coloredWindowStyle(window)}
|
||||
style={coloredWindowStyle(window, timelineDuration)}
|
||||
title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
|
||||
role="img"
|
||||
aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}
|
||||
@@ -1128,11 +1128,11 @@
|
||||
{/each}
|
||||
<div class="timeline-row summary-row">
|
||||
<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}
|
||||
<span
|
||||
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}명 중 가장 높은 효과` : ''}`}
|
||||
role="img"
|
||||
aria-label={`${resultCardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
|
||||
@@ -1142,11 +1142,11 @@
|
||||
</div>
|
||||
<div class="timeline-row summary-row">
|
||||
<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}
|
||||
<span
|
||||
class="skill-window special-window"
|
||||
style={coloredWindowStyle(window)}
|
||||
style={coloredWindowStyle(window, timelineDuration)}
|
||||
title={`${resultCardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
|
||||
role="img"
|
||||
aria-label={`${resultCardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
|
||||
@@ -1156,8 +1156,8 @@
|
||||
</div>
|
||||
<div class="timeline-row axis-row" aria-hidden="true">
|
||||
<span class="lane-label axis-label"></span>
|
||||
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
|
||||
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
|
||||
<div class="timeline-track time-axis" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
|
||||
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second, timelineDuration)}%;`}>{second}초</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user