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>
+7 -29
View File
@@ -9,6 +9,7 @@
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts'; import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts'; import { releasedSongCharts } from '$lib/data/song-release.ts';
import PresetManager from '$lib/components/PresetManager.svelte'; import PresetManager from '$lib/components/PresetManager.svelte';
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
import SongPicker from '$lib/components/SongPicker.svelte'; import SongPicker from '$lib/components/SongPicker.svelte';
import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts'; import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
@@ -156,9 +157,7 @@
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null); $: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값'; $: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: timelineDuration = selectedChart?.durationSeconds ?? 0; $: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : [];
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []); $: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
$: timelineTicks = buildTimelineTicks(timelineDuration); $: timelineTicks = buildTimelineTicks(timelineDuration);
$: timelineWidth = Math.max(720, timelineDuration * 6); $: timelineWidth = Math.max(720, timelineDuration * 6);
$: candidateCount = (() => { $: candidateCount = (() => {
@@ -691,21 +690,10 @@
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR'); return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
} }
function countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] {
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0);
for (const note of scoreResult.notes) {
const second = Math.floor(note.timeSeconds);
if (second >= 0 && second < counts.length) counts[second] += 1;
}
return counts;
}
function buildTimelineTicks(durationSeconds: number): number[] { function buildTimelineTicks(durationSeconds: number): number[] {
if (durationSeconds <= 0) return []; if (durationSeconds <= 0) return [];
const interval = 10; const interval = 10;
const ticks = Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval); return Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval);
if (ticks.at(-1) !== durationSeconds) ticks.push(durationSeconds);
return ticks;
} }
function timelinePosition(seconds: number): number { function timelinePosition(seconds: number): number {
@@ -717,7 +705,7 @@
} }
function timelineTrackStyle(): string { function timelineTrackStyle(): string {
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`; return `min-width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
} }
function memberColor(cardId: string): string { function memberColor(cardId: string): string {
@@ -948,14 +936,7 @@
</div> </div>
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프"> <div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}> <div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<div class="timeline-row note-row"> <ScoreTimelineChart notes={result.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
<strong class="lane-label">초당 노트</strong>
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
{#each noteCountsBySecond as count, second}
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
{/each}
</div>
</div>
{#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>
@@ -1133,16 +1114,13 @@
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } .timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; } .timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; } .timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; } .timeline-canvas { min-width: 100%; padding: 12px 0 8px; }
.timeline-row { display: flex; min-height: 36px; } .timeline-row { display: flex; min-height: 36px; }
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); } .timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } .lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 11px; } .lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 11px; }
.timeline-track { position: relative; flex: none; } .timeline-track { position: relative; flex: 1 0 auto; }
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; } .timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
.note-row { min-height: 68px; }
.note-track { height: 52px; align-self: center; border-bottom: 1px solid #465062; }
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
.skill-track { height: 36px; } .skill-track { height: 36px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); } .skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 12px; height: 12px; opacity: .72; } .active-window { top: 12px; height: 12px; opacity: .72; }
@@ -1189,6 +1167,6 @@
.timeline-heading { align-items: flex-start; flex-direction: column; } .timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; } .timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; } .lane-label { flex-basis: 118px; min-width: 118px; }
.timeline-canvas { padding-right: 8px; } .timeline-canvas { padding-right: 0; }
} }
</style> </style>