From 1fa15cbe08e3176073aba50fda756d28c5294a12 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Thu, 10 Sep 2026 14:40:07 +0900 Subject: [PATCH] feat: show cumulative maximum score probability in timeline tooltip --- UPDATES.md | 6 +++- src/lib/components/ScoreTimelineChart.svelte | 23 +++++++++----- src/lib/engine/score-probability.test.ts | 30 ++++++++++++++++++ src/lib/engine/score-probability.ts | 32 +++++++++++++++++--- src/routes/analysis/+page.svelte | 10 +++++- 5 files changed, 88 insertions(+), 13 deletions(-) diff --git a/UPDATES.md b/UPDATES.md index 851a658..3388c95 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-09-10T10:52:51+09:00 +updatedAt: 2026-09-10T14:39:25+09:00 --- ## 2026년 9월 10일 +### 시간별 최고점 유지 확률 + +`NEW` 배치 분석의 시간별 점수 그래프에 커서를 올리면 해당 시각까지 최고점을 유지할 확률을 함께 표시합니다. 모든 노트 PERFECT와 액티브 발동 확률을 기준으로 계산하며, 액티브 미발동 토글 시 현재 설정의 최고점 기준으로 갱신됩니다. + ### 액티브 점수 영향력 표시 `NEW` 배치 분석에서 현재 설정의 최고점에 미치는 영향이 큰 액티브를 진한 색과 굵은 테두리로 강조합니다. 구간에 마우스를 올리면 토글 시 점수 변화를 확인할 수 있으며, 미발동 구간은 복원 시 영향력을 표시합니다. diff --git a/src/lib/components/ScoreTimelineChart.svelte b/src/lib/components/ScoreTimelineChart.svelte index 2497414..c1fbeda 100644 --- a/src/lib/components/ScoreTimelineChart.svelte +++ b/src/lib/components/ScoreTimelineChart.svelte @@ -2,6 +2,7 @@ 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[] = []; @@ -10,6 +11,7 @@ timeSeconds: number; averageScore: number; maximumScore: number; + maximumProbability?: number; }; const HEIGHT = 220; @@ -25,7 +27,7 @@ $: viewWidth = Math.max(320, chartWidth); $: plotWidth = viewWidth - LEFT - RIGHT; - $: points = buildPoints(notes); + $: 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); @@ -41,14 +43,14 @@ tooltipPercent = 0; } - function buildPoints(source: readonly NoteScorePoint[]): ScorePoint[] { + function buildPoints(source: readonly NoteScorePoint[], probabilities: readonly number[]): ScorePoint[] { let averageScore = 0; let maximumScore = 0; - const result: ScorePoint[] = [{ timeSeconds: 0, averageScore: 0, maximumScore: 0 }]; - for (const note of source) { + 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 }); + result.push({ timeSeconds: note.timeSeconds, averageScore, maximumScore, maximumProbability: probabilities[index] }); } return result; } @@ -114,6 +116,12 @@ 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')}만`; } @@ -147,7 +155,7 @@ 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)}` : '방향키로 시각을 선택하세요'} + 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} @@ -176,10 +184,11 @@ {#if hoveredPoint && hoverTime !== null} -
68} class="tooltip" style={`left: ${tooltipPercent}%;`} role="status"> +
50} class="tooltip" style={`left: ${tooltipPercent}%;`} role="status"> {formatTime(hoverTime)} 평균 점수 {formatScore(hoveredPoint.averageScore)} 최고 점수 {formatScore(hoveredPoint.maximumScore)} + 이 시간까지 최고점 유지 확률 {formatProbability(hoveredPoint.maximumProbability)}
{/if}
diff --git a/src/lib/engine/score-probability.test.ts b/src/lib/engine/score-probability.test.ts index 24d6b80..2dcc56a 100644 --- a/src/lib/engine/score-probability.test.ts +++ b/src/lib/engine/score-probability.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { estimateScoreAchievementProbabilities } from './index.ts'; +import { calculateMaximumScoreProbabilityTimeline } from './score-probability.ts'; import type { ExactSongChart, OptimizerCard } from './types.ts'; function card( @@ -104,3 +105,32 @@ test('확정 발동이면 모든 달성 확률이 100%다', () => { samples: 1_000 }); }); + +test('시간별 최고점 확률은 첫 영향 노트에서 감소하고 같은 발동을 중복 계산하지 않는다', () => { + const cards = [card('main', 100, 50, 0.9), card('weak', 20, 10)]; + const source = chart([0.5, 1.1, 1.5, 1.95, 2.1, 2.1]); + const options = { perfectNoteScore: 100, sampleCount: 1 }; + const probabilities = calculateMaximumScoreProbabilityTimeline(cards, {}, source, options); + // The weak skill alone raises the maximum at 1.95 seconds. + assert.deepEqual(probabilities, [1, 0.5, 0.5, 0.05, 0.025, 0.025]); + assert.equal(probabilities.at(-1), estimateScoreAchievementProbabilities(cards, {}, source, options).maximum); + assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([]), options), []); +}); + +test('시간별 확률은 겹친 동률 액티브와 이후 필수 발동 사이의 의존성을 반영한다', () => { + const cards = [card('long', 100, 50, 0.9), card('short', 100, 50, 0.3)]; + assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1, 1.5, 2.1]), { + perfectNoteScore: 100 + }), [0.75, 0.5, 0.375]); +}); + +test('미발동 토글 시 현재 최고점 기준으로 확률을 계산하고 반올림으로 점수가 같으면 실패를 허용한다', () => { + const cards = [card('strong', 100, 50), card('weak', 20, 10)]; + assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1, 2.1]), { + perfectNoteScore: 100, + excludedActiveWindows: [{ cardId: 'strong', startSeconds: 1 }] + }), [0.1, 0.05]); + const options = { perfectNoteScore: 0.1, sampleCount: 1 }; + assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1]), options), [1]); + assert.equal(estimateScoreAchievementProbabilities(cards, {}, chart([1.1]), options).maximum, 1); +}); diff --git a/src/lib/engine/score-probability.ts b/src/lib/engine/score-probability.ts index 444a1d3..61a54f8 100644 --- a/src/lib/engine/score-probability.ts +++ b/src/lib/engine/score-probability.ts @@ -62,7 +62,8 @@ function prepareModel( cards: readonly OptimizerCard[], reductions: Readonly>, chart: ExactSongChart, - options: ExactScoreOptions + options: ExactScoreOptions, + onNote?: (probability: number) => void ): PreparedProbabilityModel { const attempts: Attempt[] = []; const attemptIndexes = new Map(); @@ -70,6 +71,8 @@ function prepareModel( const grouped = new Map(); const clauses: number[][] = []; let maximumScore = 0; + let prefixProbability = 1; + let prefixClauses: number[][] = []; scoreNotes(chart).forEach((note, noteIndex) => { const candidates: NoteCandidate[] = []; @@ -80,6 +83,8 @@ function prepareModel( const attemptNumber = Math.floor((note.timeSeconds + 1e-9) / interval); if (attemptNumber <= 0) return; const activationTime = attemptNumber * interval; + if (options.excludedActiveWindows?.some((window) => + window.cardId === card.id && Math.abs(window.startSeconds - activationTime) < 1e-9)) return; if (note.timeSeconds >= activationTime + card.active.durationSeconds) return; const key = `${card.id}:${attemptNumber}`; let attemptIndex = attemptIndexes.get(key); @@ -110,11 +115,18 @@ function prepareModel( ]; maximumScore += scores[0]; - if (candidates.length > 0) { - clauses.push(candidates + // No activation is required when rounding gives the baseline the same score. + if (scores[0] > scores[scores.length - 1]) { + const clause = candidates .filter((_, index) => scores[index] === scores[0]) - .map((candidate) => candidate.attemptIndex)); + .map((candidate) => candidate.attemptIndex); + clauses.push(clause); + if (onNote && !prefixClauses.some((existing) => existing.every((variable) => clause.includes(variable)))) { + prefixClauses = simplifyClauses([...prefixClauses, clause]); + prefixProbability = exactClauseProbability(prefixClauses, attempts); + } } + onNote?.(prefixProbability); const signature = candidates.map((candidate) => `${candidate.attemptIndex}:${candidate.boost.toPrecision(15)}`).join('|'); const existing = grouped.get(signature); @@ -130,6 +142,18 @@ function prepareModel( }; } +/** Exact cumulative maximum-score probability after each chart note, in note order. */ +export function calculateMaximumScoreProbabilityTimeline( + cards: readonly OptimizerCard[], + reductions: Readonly>, + chart: ExactSongChart, + options: ExactScoreOptions +): number[] { + const probabilities: number[] = []; + prepareModel(cards, reductions, chart, options, (probability) => probabilities.push(probability)); + return probabilities; +} + function simplifyClauses(clauses: readonly (readonly number[])[]): number[][] { const unique = new Map(); for (const clause of clauses) { diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index c9724a1..cbf25d4 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -18,6 +18,7 @@ import { releasedSongCharts } from '$lib/data/song-release.ts'; import PresetManager from '$lib/components/PresetManager.svelte'; import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte'; + import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts'; import SongPicker from '$lib/components/SongPicker.svelte'; import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts'; @@ -134,6 +135,13 @@ excludedActiveWindows = []; } $: displayedResult = simulatedResult ?? result; + $: maximumScoreProbabilities = result && simulationContext + ? calculateMaximumScoreProbabilityTimeline( + result.order.map((id) => simulationContext!.cards.find((card) => card.id === id)!), + result.cooldownReductions, simulationContext.chart, + { ...simulationContext.options, excludedActiveWindows } + ) + : []; $: activeImpacts = result && simulationContext && mode === 'analysis' ? calculateActiveImpacts( result.order.map((id) => simulationContext!.cards.find((card) => card.id === id)!), @@ -1215,7 +1223,7 @@ {/if}
- + {#each result.order as cardId, slot}
{slot + 1}{resultCardName(cardId)}