feat: emphasize active skill windows by score impact
Publish GitHub Pages output / publish (push) Successful in 50s

This commit is contained in:
2026-09-10 11:00:16 +09:00
parent 089bb69ffd
commit 3f21819b39
5 changed files with 150 additions and 7 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
---
updatedAt: 2026-09-09T19:13:48+09:00
updatedAt: 2026-09-10T10:52:51+09:00
---
## 2026년 9월 10일
### 액티브 점수 영향력 표시
`NEW` 배치 분석에서 현재 설정의 최고점에 미치는 영향이 큰 액티브를 진한 색과 굵은 테두리로 강조합니다. 구간에 마우스를 올리면 토글 시 점수 변화를 확인할 수 있으며, 미발동 구간은 복원 시 영향력을 표시합니다.
## 2026년 9월 9일
### 조합 탐색 결과 출력 수 선택
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activeWindowKey, calculateActiveImpacts } from './active-impact.ts';
import { buildExactSkillWindows } from './exact-optimizer.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
test('영향력은 겹친 액티브와 스페셜을 반영하며 제외 후 복원 효과로 갱신된다', () => {
const cards: OptimizerCard[] = [100, 50].map((scoreUpPercent, index) => ({
id: String(index), name: String(index),
active: { cooldownSeconds: 10, durationSeconds: 2, scoreUpPercent },
special: { durationSeconds: 2, scoreUpPercent: index === 0 ? 100 : 0 }
}));
const chart: ExactSongChart = {
songId: 'synthetic', difficulty: 'expert', durationSeconds: 22, scoreRatio: 1,
notes: [10, 20].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
specialStartSeconds: [10, null]
};
const windows = buildExactSkillWindows(cards, {}, chart).activeWindows;
const key = (cardId: string, startSeconds: number) => activeWindowKey({ cardId, startSeconds });
const impacts = calculateActiveImpacts(cards, {}, chart, { perfectNoteScore: 1000 }, windows);
assert.deepEqual(impacts.get(key('0', 10)), { scoreChange: -1000, strength: 1 });
assert.deepEqual(impacts.get(key('0', 20)), { scoreChange: -500, strength: 0.5 });
assert.deepEqual(impacts.get(key('1', 10)), { scoreChange: 0, strength: 0 });
const after = calculateActiveImpacts(cards, {}, chart, {
perfectNoteScore: 1000, excludedActiveWindows: [{ cardId: '0', startSeconds: 10 }]
}, windows);
assert.deepEqual(after.get(key('0', 10)), { scoreChange: 1000, strength: 1 });
assert.deepEqual(after.get(key('1', 10)), { scoreChange: -1000, strength: 1 });
const empty = calculateActiveImpacts(cards, {}, { ...chart, notes: [] }, { perfectNoteScore: 1000 }, windows);
assert.ok([...empty.values()].every((value) => value.scoreChange === 0 && value.strength === 0));
});
+31
View File
@@ -0,0 +1,31 @@
import { calculateExactScore } from './score.ts';
import type { CooldownReduction, ExactScoreOptions, ExactSongChart, OptimizerCard, SkillWindow } from './types.ts';
export function activeWindowKey(window: Pick<SkillWindow, 'cardId' | 'startSeconds'>): string {
return JSON.stringify([window.cardId, window.startSeconds]);
}
/** Current-setting marginal maximum-score change when each activation is toggled. */
export function calculateActiveImpacts(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions,
windows: readonly SkillWindow[]
): Map<string, { scoreChange: number; strength: number }> {
const excluded = options.excludedActiveWindows ?? [];
const excludedKeys = new Set(excluded.map(activeWindowKey));
const current = calculateExactScore(cards, reductions, chart, options).theoreticalMaxScore;
const changes = windows.map((window) => {
const key = activeWindowKey(window);
const toggled = excludedKeys.has(key)
? excluded.filter((entry) => activeWindowKey(entry) !== key)
: [...excluded, window];
const score = calculateExactScore(cards, reductions, chart, { ...options, excludedActiveWindows: toggled });
return { key, scoreChange: score.theoreticalMaxScore - current };
});
const maximum = Math.max(0, ...changes.map((entry) => Math.abs(entry.scoreChange)));
return new Map(changes.map(({ key, scoreChange }) => [key, {
scoreChange, strength: maximum > 0 ? Math.abs(scoreChange) / maximum : 0
}]));
}
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateExactScore } from './score.ts';
import { buildExactSkillWindows } from './exact-optimizer.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const strong: OptimizerCard = {
id: 'strong', name: 'strong',
active: { cooldownSeconds: 10, durationSeconds: 4, scoreUpPercent: 100, probabilityPercent: 50 },
special: { durationSeconds: 2, scoreUpPercent: 145 }
};
const weak: OptimizerCard = {
id: 'weak', name: 'weak',
active: { cooldownSeconds: 10, durationSeconds: 4, scoreUpPercent: 50, probabilityPercent: 100 },
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
const chart: ExactSongChart = {
songId: 'synthetic-special-overlap', difficulty: 'expert', durationSeconds: 25, scoreRatio: 1,
specialStartSeconds: [11, null],
notes: [10, 11, 12, 13, 14, 20].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false }))
};
const excludedActiveWindows = [{ cardId: 'strong', startSeconds: 10 }];
const options = { perfectNoteScore: 1000 };
test('스페셜 중첩 발동을 제외해도 같은 카드의 스페셜은 대체 액티브에 유지된다', () => {
const before = calculateExactScore([strong, weak], {}, chart, options);
const after = calculateExactScore([strong, weak], {}, chart, { ...options, excludedActiveWindows });
// 11~13초에는 액티브 100%/50%에 서포트 145%를 한 번만 적용한다.
assert.deepEqual(before.notes.map((note) => note.theoreticalMaxScore), [2000, 3450, 3450, 2000, 1000, 2000]);
assert.deepEqual(after.notes.map((note) => note.theoreticalMaxScore), [1500, 2225, 2225, 1500, 1000, 2000]);
assert.equal(before.theoreticalMaxScore - after.theoreticalMaxScore, 3450);
assert.deepEqual(before.notes.map((note, index) => note.theoreticalMaxScore - after.notes[index].theoreticalMaxScore), [500, 1225, 1225, 500, 0, 0]);
const windows = buildExactSkillWindows([strong, weak], {}, chart, excludedActiveWindows);
assert.deepEqual(windows.specialWindows, buildExactSkillWindows([strong, weak], {}, chart).specialWindows);
const winner = windows.effectiveActiveWindows.find((window) => window.startSeconds <= 11 && 11 < window.endSeconds);
assert.equal(winner?.cardId, 'weak');
assert.ok(Math.abs((winner?.scoreUpPercent ?? 0) - 122.5) < 1e-9);
assert.deepEqual(calculateExactScore([strong, weak], {}, chart, { ...options, excludedActiveWindows: [] }), before);
});
test('대체 액티브가 없으면 스페셜 중첩 부분을 포함한 발동 전체의 증가분만 사라진다', () => {
const before = calculateExactScore([strong], {}, chart, options);
const after = calculateExactScore([strong], {}, chart, { ...options, excludedActiveWindows });
assert.deepEqual(after.notes.map((note) => note.theoreticalMaxScore), [1000, 1000, 1000, 1000, 1000, 2000]);
assert.equal(before.theoreticalMaxScore - after.theoreticalMaxScore, 6900);
assert.equal(before.baselineScore, after.baselineScore);
// 최고점 변화는 성공을 가정한다. 발동률 50%를 적용한 기대값 변화와 다르다.
assert.equal(before.expectedScore - after.expectedScore, 3450);
});
test('스페셜이 중첩된 발동도 여러 토글의 증감 합과 원래 대비 차이가 일치한다', () => {
const cards = [strong, weak];
const before = calculateExactScore(cards, {}, chart, options);
const first = calculateExactScore(cards, {}, chart, { ...options, excludedActiveWindows });
const both = calculateExactScore(cards, {}, chart, {
...options, excludedActiveWindows: [...excludedActiveWindows, { cardId: 'weak', startSeconds: 10 }]
});
assert.equal(before.theoreticalMaxScore - first.theoreticalMaxScore, 3450);
assert.equal(first.theoreticalMaxScore - both.theoreticalMaxScore, 3450);
assert.equal(before.theoreticalMaxScore - both.theoreticalMaxScore, 6900);
const weakOnly = calculateExactScore(cards, {}, chart, {
...options, excludedActiveWindows: [{ cardId: 'weak', startSeconds: 10 }]
});
assert.equal(weakOnly.theoreticalMaxScore, before.theoreticalMaxScore);
assert.ok(weakOnly.expectedScore < before.expectedScore);
});
+15 -6
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
import { onMount, tick } from 'svelte';
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
import { analytics } from '$lib/analytics.ts';
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
@@ -133,6 +134,13 @@
excludedActiveWindows = [];
}
$: displayedResult = simulatedResult ?? result;
$: activeImpacts = result && simulationContext && mode === 'analysis'
? calculateActiveImpacts(
result.order.map((id) => simulationContext!.cards.find((card) => card.id === id)!),
result.cooldownReductions, simulationContext.chart,
{ ...simulationContext.options, excludedActiveWindows }, result.activeWindows
)
: new Map<string, { scoreChange: number; strength: number }>();
function isExcluded(window: SkillWindow): boolean {
return excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds);
@@ -1197,7 +1205,7 @@
</ol>
</details>
{/if}
<p class="simulation-guide" aria-live="polite">카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다.
<p class="simulation-guide" aria-live="polite">카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다. 색이 진하고 테두리가 굵을수록 현재 설정에서 토글할 때 최고점 변화가 큽니다. 빗금은 미발동 상태이며, 해당 구간은 복원 시 영향력을 표시합니다.
{#if excludedActiveWindows.length}
<strong>{excludedActiveWindows.length}회 미발동 적용 중</strong>
<button type="button" on:click={resetActiveWindows}>모두 복원</button>
@@ -1213,14 +1221,15 @@
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
{@const impact = activeImpacts.get(activeWindowKey(window))}
<button
type="button"
class="skill-window active-window"
class:excluded={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
disabled={mode !== 'analysis' || !simulationContext || running}
style={coloredWindowStyle(window, timelineDuration)}
title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원`}
aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리`}
style={coloredWindowStyle(window, timelineDuration) + (impact ? ` --impact-opacity: ${0.25 + 0.75 * Math.sqrt(impact.strength)}; --impact-border: ${impact.strength > 0 ? 1 + 2 * impact.strength : 0}px;` : '')}
title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원${impact ? ` · 토글 시 최고점 ${signedScoreChange(impact.scoreChange)}` : ''}`}
aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리${impact ? ` · 토글 시 최고점 ${signedScoreChange(impact.scoreChange)}` : ''}`}
aria-pressed={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
on:click={() => toggleActiveWindow(window)}
></button>
@@ -1405,9 +1414,9 @@
.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; }
.skill-track { height: 36px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 8px; height: 20px; opacity: .72; padding: 0; border: 0; cursor: pointer; }
.active-window { top: 8px; height: 20px; opacity: var(--impact-opacity, .72); padding: 0; border: 0; box-shadow: inset 0 0 0 var(--impact-border, 0px) rgb(255 255 255 / 85%); cursor: pointer; }
.active-window:hover:not(:disabled), .active-window:focus-visible { opacity: 1; outline: 2px solid #fff; outline-offset: 2px; }
.active-window.excluded { opacity: .28; border: 1px dashed var(--member-color); background: repeating-linear-gradient(135deg, transparent 0 3px, var(--member-color) 3px 4px); }
.active-window.excluded { opacity: var(--impact-opacity, .28); border: 1px dashed var(--member-color); background: repeating-linear-gradient(135deg, transparent 0 3px, var(--member-color) 3px 4px); }
.active-window:disabled { cursor: default; }
.timeline-score-summary { display: flex; flex-wrap: wrap; gap: 16px 32px; padding: 16px; margin-bottom: 12px; border: 1px solid #454052; border-radius: 10px; background: #22202a; }
.timeline-score-summary > div { display: grid; gap: 6px; }