feat: show cumulative maximum score probability in timeline tooltip
Publish GitHub Pages output / publish (push) Successful in 56s

This commit is contained in:
2026-09-10 14:40:07 +09:00
parent b1b99e6cc0
commit 1fa15cbe08
5 changed files with 88 additions and 13 deletions
+5 -1
View File
@@ -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` 배치 분석에서 현재 설정의 최고점에 미치는 영향이 큰 액티브를 진한 색과 굵은 테두리로 강조합니다. 구간에 마우스를 올리면 토글 시 점수 변화를 확인할 수 있으며, 미발동 구간은 복원 시 영향력을 표시합니다.
+16 -7
View File
@@ -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 @@
</svg>
{#if hoveredPoint && hoverTime !== null}
<div class:align-right={tooltipPercent > 68} class="tooltip" style={`left: ${tooltipPercent}%;`} role="status">
<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>
+30
View File
@@ -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);
});
+28 -4
View File
@@ -62,7 +62,8 @@ function prepareModel(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions
options: ExactScoreOptions,
onNote?: (probability: number) => void
): PreparedProbabilityModel {
const attempts: Attempt[] = [];
const attemptIndexes = new Map<string, number>();
@@ -70,6 +71,8 @@ function prepareModel(
const grouped = new Map<string, { candidates: NoteCandidate[]; scores: number[] }>();
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<Record<string, CooldownReduction>>,
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<string, number[]>();
for (const clause of clauses) {
+9 -1
View File
@@ -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}
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<ScoreTimelineChart notes={displayedResult!.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
<ScoreTimelineChart notes={displayedResult!.notes} {maximumScoreProbabilities} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
{#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>