feat: add combination search workflow

This commit is contained in:
2026-08-18 16:30:51 +09:00
parent 7b713bffe3
commit f953fe9970
10 changed files with 1294 additions and 24 deletions
@@ -0,0 +1,217 @@
<script lang="ts">
import type { EffectiveSkillWindow, ExactOptimizationResult, SkillWindow } from '$lib/engine/index.ts';
type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number;
minScoreUpPercent: number;
maxScoreUpPercent: number;
maxOverlapCount: number;
};
export let result: ExactOptimizationResult;
export let durationSeconds: number;
export let cardName: (cardId: string) => string;
export let instanceId = 'result';
export let baseline: ExactOptimizationResult | null = null;
export let showScoreSummary = true;
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
$: noteCountsBySecond = countNotesBySecond(result, durationSeconds);
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result.effectiveActiveWindows ?? []);
$: timelineTicks = buildTimelineTicks(durationSeconds);
$: timelineWidth = Math.max(720, durationSeconds * 6);
function countNotesBySecond(scoreResult: ExactOptimizationResult, duration: number): number[] {
const counts = Array.from({ length: Math.ceil(duration) }, () => 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(duration: number): number[] {
if (duration <= 0) return [];
const ticks = Array.from({ length: Math.floor(duration / 10) + 1 }, (_, index) => index * 10);
if (ticks.at(-1) !== duration) ticks.push(duration);
return ticks;
}
function timelinePosition(seconds: number): number {
return durationSeconds > 0 ? Math.max(0, Math.min(100, seconds / durationSeconds * 100)) : 0;
}
function skillWindowStyle(startSeconds: number, endSeconds: number): string {
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`;
}
function timelineTrackStyle(): string {
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
}
function memberColor(cardId: string): string {
const index = result.order.indexOf(cardId);
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
}
function coloredWindowStyle(window: SkillWindow): string {
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`;
}
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
const merged: DisplayEffectiveWindow[] = [];
for (const window of windows) {
const previous = merged.at(-1);
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
previous.endSeconds = window.endSeconds;
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
continue;
}
merged.push({
...window,
minScoreUpPercent: window.scoreUpPercent,
maxScoreUpPercent: window.scoreUpPercent,
maxOverlapCount: window.overlapCount
});
}
return merged;
}
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
const minimum = Number(window.minScoreUpPercent.toFixed(1));
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
}
function formatSeconds(seconds: number): string {
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
}
function delta(current: number, reference: number): string {
const difference = current - reference;
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
}
</script>
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
{#if showScoreSummary}
<div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div>
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.theoreticalMaxScore, baseline.theoreticalMaxScore)}</small>{/if}</div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.baselineScore, baseline.baselineScore)}</small>{/if}</div>
</div>
{/if}
<section class="skill-timeline" aria-labelledby={`timeline-title-${instanceId}`}>
<div class="timeline-heading">
<div><h3 id={`timeline-title-${instanceId}`}>스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
<div class="timeline-legend" aria-label="멤버 색상 범례">
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{cardName(cardId)}</span>{/each}
</div>
</div>
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<div class="timeline-row note-row">
<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}
<div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{cardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
<span class="skill-window active-window" style={coloredWindowStyle(window)} title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}></span>
{/each}
</div>
</div>
{/each}
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">최종 액티브</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each displayedEffectiveActiveWindows as window}
<span class="skill-window effective-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`} role="img" aria-label={`${cardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
{/each}
</div>
</div>
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">스페셜</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.specialWindows as window}
<span class="skill-window special-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`${cardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
{/each}
</div>
</div>
<div class="timeline-row axis-row" aria-hidden="true">
<span class="lane-label axis-label"></span>
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
</div>
</div>
</div>
</div>
</section>
<style>
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 11px; }
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 12px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; }
.score-summary small { color: #90d8ae; font-size: 10px; font-weight: 800; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
.timeline-heading h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 11px; }
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 10px; }
.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-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-row { display: flex; min-height: 36px; }
.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: 11px; 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: 9px; }
.timeline-track { position: relative; flex: 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-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 12px; height: 12px; opacity: .72; }
.summary-row { background: rgb(255 255 255 / 1.5%); }
.summary-label { color: #e1e5ec; font-weight: 800; }
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
.special-window { top: 11px; height: 14px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
.axis-row { min-height: 24px; border-top: 1px solid #343c49 !important; }
.time-axis { height: 24px; }
.time-axis::before { display: none; }
.time-axis span { position: absolute; top: 5px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; }
.time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); }
@media (max-width: 700px) {
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; }
.timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; }
.timeline-canvas { padding-right: 8px; }
}
</style>