feat: show cumulative maximum score probability in timeline tooltip
Publish GitHub Pages output / publish (push) Successful in 56s
Publish GitHub Pages output / publish (push) Successful in 56s
This commit is contained in:
+5
-1
@@ -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` 배치 분석에서 현재 설정의 최고점에 미치는 영향이 큰 액티브를 진한 색과 굵은 테두리로 강조합니다. 구간에 마우스를 올리면 토글 시 점수 변화를 확인할 수 있으며, 미발동 구간은 복원 시 영향력을 표시합니다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user