228 lines
11 KiB
Svelte
228 lines
11 KiB
Svelte
<script lang="ts">
|
|
import type { NoteScorePoint } from '$lib/engine/index.ts';
|
|
|
|
export let notes: readonly NoteScorePoint[];
|
|
export let maximumScoreProbabilities: readonly number[] = [];
|
|
export let durationSeconds: number;
|
|
export let minimumWidth = 720;
|
|
export let timeTicks: readonly number[] = [];
|
|
|
|
type ScorePoint = {
|
|
timeSeconds: number;
|
|
averageScore: number;
|
|
maximumScore: number;
|
|
maximumProbability?: 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, maximumScoreProbabilities);
|
|
$: 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);
|
|
|
|
// 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[], probabilities: readonly number[]): ScorePoint[] {
|
|
let averageScore = 0;
|
|
let maximumScore = 0;
|
|
const result: ScorePoint[] = [{ timeSeconds: 0, averageScore: 0, maximumScore: 0, maximumProbability: 1 }];
|
|
for (const [index, note] of source.entries()) {
|
|
averageScore += note.expectedScore;
|
|
maximumScore += note.theoreticalMaxScore;
|
|
result.push({ timeSeconds: note.timeSeconds, averageScore, maximumScore, maximumProbability: probabilities[index] });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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: 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, 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, 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, duration).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 formatProbability(probability: number | undefined): string {
|
|
if (probability === undefined) return '—';
|
|
if (probability > 0 && probability < 0.000001) return '<0.0001%';
|
|
return `${(probability * 100).toLocaleString('ko-KR', { maximumFractionDigits: probability < 0.0001 ? 4 : 2 })}%`;
|
|
}
|
|
|
|
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, chartMaximumScore)}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)}, 이 시간까지 최고점 유지 확률 ${formatProbability(hoveredPoint.maximumProbability)}` : '방향키로 시각을 선택하세요'}
|
|
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, 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, 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, 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>
|
|
|
|
{#if hoveredPoint && hoverTime !== null}
|
|
<div class:align-right={tooltipPercent > 50} 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>
|
|
<span>이 시간까지 최고점 유지 확률 <b>{formatProbability(hoveredPoint.maximumProbability)}</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 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; }
|
|
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>
|