feat: add score timeline to batch analysis

This commit is contained in:
2026-08-31 04:51:16 +09:00
parent d85a4ddfbf
commit 06e7edcceb
2 changed files with 217 additions and 29 deletions
@@ -0,0 +1,210 @@
<script lang="ts">
import type { NoteScorePoint } from '$lib/engine/index.ts';
export let notes: readonly NoteScorePoint[];
export let durationSeconds: number;
export let minimumWidth = 720;
export let timeTicks: readonly number[] = [];
type ScorePoint = {
timeSeconds: number;
averageScore: number;
maximumScore: number;
};
const HEIGHT = 220;
const LEFT = 0;
const RIGHT = 0;
const TOP = 8;
const BOTTOM = 8;
const PLOT_HEIGHT = HEIGHT - TOP - BOTTOM;
let chartWidth = 1000;
let hoverTime: number | null = null;
let tooltipPercent = 0;
$: viewWidth = Math.max(320, chartWidth);
$: plotWidth = viewWidth - LEFT - RIGHT;
$: points = buildPoints(notes);
$: finalMaximumScore = Math.max(1, points.at(-1)?.maximumScore ?? 0);
$: chartMaximumScore = Math.max(500_000, Math.ceil(finalMaximumScore / 500_000) * 500_000);
$: averagePath = linePath(points, 'averageScore', durationSeconds, chartMaximumScore, viewWidth);
$: maximumPath = linePath(points, 'maximumScore', durationSeconds, chartMaximumScore, viewWidth);
$: yTicks = Array.from({ length: chartMaximumScore / 500_000 + 1 }, (_, index) => index * 500_000);
$: hoveredPoint = hoverTime === null ? null : pointAtTime(points, hoverTime);
function buildPoints(source: readonly NoteScorePoint[]): ScorePoint[] {
let averageScore = 0;
let maximumScore = 0;
const result: ScorePoint[] = [{ timeSeconds: 0, averageScore: 0, maximumScore: 0 }];
for (const note of source) {
averageScore += note.expectedScore;
maximumScore += note.theoreticalMaxScore;
result.push({ timeSeconds: note.timeSeconds, averageScore, maximumScore });
}
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 y(score: number, scaleMaximum = chartMaximumScore): 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)}`];
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)}`
);
}
const last = source.at(-1)!;
commands.push(`L ${x(duration, width).toFixed(2)} ${y(last[field], scaleMaximum).toFixed(2)}`);
return commands.join(' ');
}
function pointAtTime(source: readonly ScorePoint[], timeSeconds: number): ScorePoint {
let low = 0;
let high = source.length - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (source[middle].timeSeconds <= timeSeconds) low = middle;
else high = middle - 1;
}
return source[low] ?? { timeSeconds: 0, averageScore: 0, maximumScore: 0 };
}
function updateHover(clientX: number, target: EventTarget | null): void {
if (!(target instanceof HTMLElement)) return;
const bounds = target.getBoundingClientRect();
const plotLeft = bounds.left + bounds.width * LEFT / viewWidth;
const renderedPlotWidth = bounds.width * plotWidth / viewWidth;
const ratio = Math.max(0, Math.min(1, (clientX - plotLeft) / Math.max(1, renderedPlotWidth)));
hoverTime = ratio * durationSeconds;
tooltipPercent = (LEFT + ratio * plotWidth) / viewWidth * 100;
}
function onPointerMove(event: PointerEvent): void {
updateHover(event.clientX, event.currentTarget);
}
function onKeydown(event: KeyboardEvent): void {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const step = event.key === 'Home' ? -durationSeconds : event.key === 'End' ? durationSeconds : event.key === 'ArrowLeft' ? -1 : 1;
hoverTime = Math.max(0, Math.min(durationSeconds, (hoverTime ?? 0) + step));
tooltipPercent = (LEFT + hoverTime / Math.max(1, durationSeconds) * plotWidth) / viewWidth * 100;
}
function formatScore(score: number): string {
return Math.round(score).toLocaleString('ko-KR');
}
function formatAxisScore(score: number): string {
return score === 0 ? '0' : `${Math.round(score / 10_000).toLocaleString('ko-KR')}만`;
}
function formatTime(timeSeconds: number): string {
const minutes = Math.floor(timeSeconds / 60);
const seconds = Math.floor(timeSeconds % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
</script>
<div class="score-row">
<strong class="lane-label">
<span class="label-heading">
<span>시간에 따른 점수</span>
<small class="legend" aria-label="점수 그래프 범례">
<span><i class="average"></i>평균</span>
<span><i class="maximum"></i>최고</span>
</small>
</span>
{#each yTicks as tick}
<span class="axis-value" style={`top: ${y(tick)}px;`}>{formatAxisScore(tick)}</span>
{/each}
</strong>
<div
class="chart-wrap score-track"
bind:clientWidth={chartWidth}
style={`min-width: ${minimumWidth}px;`}
role="slider"
aria-label="점수를 확인할 시각"
aria-valuemin="0"
aria-valuemax={Math.ceil(durationSeconds)}
aria-valuenow={Math.round(hoverTime ?? 0)}
aria-valuetext={hoveredPoint && hoverTime !== null ? `${formatTime(hoverTime)}, 평균 점수 ${formatScore(hoveredPoint.averageScore)}, 최고 점수 ${formatScore(hoveredPoint.maximumScore)}` : '방향키로 시각을 선택하세요'}
tabindex="0"
on:pointermove={onPointerMove}
on:pointerleave={() => hoverTime = null}
on:focus={() => { if (hoverTime === null) { hoverTime = 0; tooltipPercent = LEFT / viewWidth * 100; } }}
on:blur={() => hoverTime = null}
on:keydown={onKeydown}
>
<svg
viewBox={`0 0 ${viewWidth} ${HEIGHT}`}
role="img"
aria-label="시간에 따른 누적 평균 점수와 최고 점수 그래프"
>
{#each timeTicks as tick}
<line class="time-grid-line" x1={x(tick, viewWidth)} x2={x(tick, viewWidth)} 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)} />
{/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" />
{/if}
</svg>
{#if hoveredPoint && hoverTime !== null}
<div class:align-right={tooltipPercent > 68} class="tooltip" style={`left: ${tooltipPercent}%;`} role="status">
<strong>{formatTime(hoverTime)}</strong>
<span><i class="average"></i>평균 점수 <b>{formatScore(hoveredPoint.averageScore)}</b></span>
<span><i class="maximum"></i>최고 점수 <b>{formatScore(hoveredPoint.maximumScore)}</b></span>
</div>
{/if}
</div>
</div>
<style>
.score-row { display: flex; min-height: 220px; margin-bottom: 8px; }
.lane-label { position: sticky; left: 0; z-index: 4; display: block; flex: 0 0 144px; min-width: 144px; padding: 0; color: #e1e5ec; background: #141920; font-size: 12px; line-height: 1.4; }
.label-heading { position: absolute; top: 50%; left: 12px; display: grid; gap: 7px; width: 88px; transform: translateY(-50%); }
.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; }
i { display: inline-block; flex: none; width: 8px; height: 8px; border-radius: 50%; }
i.average { background: #f08db8; }
i.maximum { background: #64cbe4; }
svg { display: block; width: 100%; height: 220px; pointer-events: none; }
.chart-wrap { outline: none; touch-action: pan-y; }
.chart-wrap:focus-visible { z-index: 3; outline: 2px solid #ed8bb5; outline-offset: -2px; }
.grid-line { stroke: rgb(255 255 255 / 9%); stroke-width: 1; }
.time-grid-line { stroke: rgb(255 255 255 / 7%); stroke-width: 1; }
.score-line { fill: none; stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.5; }
.maximum-line { stroke: #64cbe4; }
.average-line { stroke: #f08db8; }
.cursor-line { stroke: rgb(255 255 255 / 58%); stroke-dasharray: 5 4; stroke-width: 1.5; }
.point { stroke: #10141a; stroke-width: 3; }
.maximum-point { fill: #64cbe4; }
.average-point { fill: #f08db8; }
.tooltip { position: absolute; z-index: 5; top: 8px; display: grid; gap: 6px; min-width: 176px; padding: 9px 10px; border: 1px solid #465160; border-radius: 8px; color: #c5ccd6; background: rgb(18 23 30 / 96%); box-shadow: 0 8px 24px rgb(0 0 0 / 35%); font-size: 11px; pointer-events: none; transform: translateX(8px); }
.tooltip.align-right { transform: translateX(calc(-100% - 10px)); }
.tooltip strong { color: #fff; font-size: 12px; }
.tooltip b { margin-left: auto; color: #edf0f4; font-size: 12px; }
@media (max-width: 700px) {
.lane-label { flex-basis: 118px; min-width: 118px; padding: 0 8px; }
}
</style>