feat: save analysis results and compare growth snapshots
Publish GitHub Pages output / publish (push) Successful in 46s
Publish GitHub Pages output / publish (push) Successful in 46s
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import AnalysisResultView from './AnalysisResultView.svelte';
|
||||
import { saveAnalysis, listAnalyses, readGrowth, deleteAnalysis, exportAnalyses, importAnalyses } from '$lib/storage/analysis-history.ts';
|
||||
import { currentGrowthSettings, snapshotKey, type AnalysisCapture, type AnalysisRecord, type AnalysisSettings, type GrowthSnapshot } from '$lib/models/analysis-history.ts';
|
||||
import type { ExactOptimizationResult } from '$lib/engine/types.ts';
|
||||
export let capture: AnalysisCapture | null = null;
|
||||
export let result: ExactOptimizationResult | null = null;
|
||||
export let cardName: (id: string) => string;
|
||||
export let currentGrowth: () => GrowthSnapshot;
|
||||
export let evaluate: (settings: AnalysisSettings, growth: GrowthSnapshot) => Promise<{ result: ExactOptimizationResult; overallPower: number }>;
|
||||
let name = '';
|
||||
let records: AnalysisRecord[] = [];
|
||||
let selected: AnalysisRecord | null = null;
|
||||
let savedGrowth: GrowthSnapshot | null = null;
|
||||
let busy = false;
|
||||
let error = '';
|
||||
let message = '';
|
||||
let comparison: { before: { result: ExactOptimizationResult; overallPower: number }; after: { result: ExactOptimizationResult; overallPower: number }; changes: string[] } | null = null;
|
||||
let phase = '';
|
||||
let detail: ExactOptimizationResult | null = null;
|
||||
onMount(() => { void refresh(); });
|
||||
async function refresh() {
|
||||
try { records = await listAnalyses(); } catch { error = '저장 기록을 열지 못했습니다. 브라우저 저장 공간 사용을 허용해 주세요.'; }
|
||||
}
|
||||
async function save() {
|
||||
if (!capture || !result || busy) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try { await saveAnalysis(capture, result, name); name = ''; await refresh(); message = '분석 결과와 당시 성장 상태를 저장했습니다.'; }
|
||||
catch { error = '저장하지 못했습니다. 브라우저 저장 공간과 사용 권한을 확인해 주세요.'; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function open(record: AnalysisRecord) {
|
||||
busy = true; error = ''; comparison = null; detail = null;
|
||||
try { savedGrowth = await readGrowth(record.snapshotId); selected = record; }
|
||||
catch (e) { error = String(e); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function remove(record: AnalysisRecord) {
|
||||
if (!confirm(`‘${record.name}’ 기록을 삭제할까요?`)) return;
|
||||
busy = true; error = '';
|
||||
try { await deleteAnalysis(record.id); if (selected?.id === record.id) { selected = null; savedGrowth = null; comparison = null; } await refresh(); }
|
||||
catch { error = '기록을 삭제하지 못했습니다.'; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
function differences(before: GrowthSnapshot, after: GrowthSnapshot, settings: AnalysisSettings): string[] {
|
||||
const changes: string[] = [];
|
||||
for (const [i, id] of settings.cardIds.entries()) {
|
||||
const next = after.collection[id]?.awakening ?? 0;
|
||||
if (settings.awakenings[i] !== next) changes.push(`${cardName(id)}: ${settings.awakenings[i]} → ${next}개화`);
|
||||
}
|
||||
if (before.memoryCount !== after.memoryCount) changes.push(`메모리: ${before.memoryCount} → ${after.memoryCount}개`);
|
||||
if (before.memberPowerUpPercent !== after.memberPowerUpPercent) changes.push(`멤버 강화: ${before.memberPowerUpPercent} → ${after.memberPowerUpPercent}%`);
|
||||
const boardKeys = new Set([...Object.keys(before.memberBoards), ...Object.keys(after.memberBoards)]);
|
||||
let added = 0, removed = 0;
|
||||
for (const id of boardKeys) {
|
||||
const old = new Set(before.memberBoards[id] ?? []), next = new Set(after.memberBoards[id] ?? []);
|
||||
added += [...next].filter((key) => !old.has(key)).length;
|
||||
removed += [...old].filter((key) => !next.has(key)).length;
|
||||
}
|
||||
if (added || removed) changes.push(`전체 보드 활성화: 추가 ${added}개 / 해제 ${removed}개`);
|
||||
if (snapshotKey(before.boardCards) !== snapshotKey(after.boardCards)) changes.push('보드 연결 카드 배치 변경');
|
||||
if (snapshotKey(before.collection) !== snapshotKey(after.collection)) changes.push('보유 카드·개화 데이터 변경 (연결 카드 강화 포함)');
|
||||
return changes;
|
||||
}
|
||||
async function compare() {
|
||||
if (!selected || !savedGrowth) return;
|
||||
busy = true; error = ''; comparison = null;
|
||||
try {
|
||||
const growth = structuredClone(currentGrowth());
|
||||
const settings = currentGrowthSettings(selected.settings, growth);
|
||||
phase = '저장 당시 성장 상태를 현재 계산식으로 분석 중';
|
||||
const before = await evaluate(selected.settings, savedGrowth);
|
||||
phase = '현재 성장 상태 분석 중';
|
||||
const after = await evaluate(settings, growth);
|
||||
comparison = { before, after, changes: differences(savedGrowth, growth, selected.settings) };
|
||||
} catch (e) { error = e instanceof Error ? e.message : '성장 비교를 완료하지 못했습니다.'; }
|
||||
finally { busy = false; phase = ''; }
|
||||
}
|
||||
async function showDetail() {
|
||||
if (!selected || !savedGrowth) return;
|
||||
busy = true; error = ''; phase = '당시 성장으로 상세 결과 재계산 중';
|
||||
try { detail = (await evaluate(selected.settings, savedGrowth)).result; }
|
||||
catch (e) { error = e instanceof Error ? e.message : '상세 결과를 계산하지 못했습니다.'; }
|
||||
finally { busy = false; phase = ''; }
|
||||
}
|
||||
async function backup() {
|
||||
busy = true; error = '';
|
||||
try {
|
||||
const url = URL.createObjectURL(new Blob([await exportAnalyses()], { type: 'application/json' }));
|
||||
const link = document.createElement('a'); link.href = url; link.download = 'holodori-analysis-history.json'; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch { error = '기록 백업을 만들지 못했습니다.'; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function restore(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
if (file.size > 50 * 1024 * 1024) throw new Error('50MB 이하의 기록 백업 파일을 선택해 주세요.');
|
||||
await importAnalyses(await file.text()); await refresh(); message = '기록을 추가했습니다. 현재 사용자 데이터는 유지됩니다.';
|
||||
} catch (e) { error = e instanceof Error ? e.message : '기록을 복원하지 못했습니다.'; }
|
||||
finally { busy = false; input.value = ''; }
|
||||
}
|
||||
function delta(a: number, b: number) { const d = b - a; return `${d >= 0 ? '+' : ''}${d.toLocaleString('ko-KR')} (${a ? `${d >= 0 ? '+' : ''}${(d / a * 100).toFixed(2)}%` : '비율 계산 불가'})`; }
|
||||
</script>
|
||||
|
||||
<section class="history" aria-label="분석 결과 저장 기록">
|
||||
<div class="history-heading"><div><span class="eyebrow">MY RECORDS</span><h2>저장된 분석 결과</h2></div><span class="count-badge">{records.length}개 기록</span></div>
|
||||
<p class="intro">오늘의 기록을 남기고, 다음 성장과 비교해 보세요.</p>
|
||||
<div class="actions">
|
||||
<input aria-label="결과 저장 이름" placeholder={capture ? `${capture.title} · ${capture.settings.difficulty}` : '저장 이름'} bind:value={name} maxlength="120" />
|
||||
<button class="primary" disabled={busy || !capture || !result} on:click={save}>+ 결과 저장</button>
|
||||
<button disabled={busy || !records.length} on:click={backup}>기록 백업</button>
|
||||
<label class="file-button" class:disabled={busy}>기록 가져오기 <input type="file" accept="application/json,.json" disabled={busy} on:change={restore} /></label>
|
||||
</div>
|
||||
<p class="hint">분석을 실행했던 시점의 설정과 성장 상태를 저장합니다. 기록 백업에는 성장 스냅샷도 포함됩니다. 일반 사용자 데이터 백업과 별도로 보관해 주세요.</p>
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
{#if message}<p role="status">{message}</p>{/if}
|
||||
{#if phase}<p role="status">{phase}…</p>{/if}
|
||||
{#if !records.length}<div class="empty"><span aria-hidden="true">◇</span><h3>첫 분석을 기록해 보세요</h3><p>분석을 마친 뒤 이름을 붙여 저장하면<br />당시의 점수와 성장 상태를 다시 꺼내볼 수 있습니다.</p></div>{/if}
|
||||
{#if records.length}<p class="hint">기록 {records.length}개 · 공유 성장 상태 {new Set(records.map((record) => record.snapshotId)).size}개</p>{/if}
|
||||
<ul class="record-grid">
|
||||
{#each records as record (record.id)}
|
||||
<li class:selected={selected?.id === record.id}>
|
||||
<button class="record" disabled={busy} on:click={() => open(record)} aria-expanded={selected?.id === record.id}>
|
||||
<span class="record-meta"><span class="difficulty">{record.settings.difficulty === 'expert' ? 'EXPERT' : 'HARD'}</span><span>{new Date(record.analyzedAt).toLocaleDateString('ko-KR')}</span></span>
|
||||
<strong class="record-name">{record.name}</strong><span class="song-name">{record.title}</span>
|
||||
<span class="members">{#each record.settings.cardIds as id}<span>{cardName(id)}</span>{/each}</span>
|
||||
<span class="record-scores"><span><small>평균 점수</small><b>{record.result.expectedScore.toLocaleString('ko-KR')}</b></span><span><small>이론상 고점</small><b>{record.result.theoreticalMaxScore.toLocaleString('ko-KR')}</b></span></span>
|
||||
<span class="open-label">{selected?.id === record.id ? '확인 중' : '기록 자세히 보기'} <span aria-hidden="true">↗</span></span>
|
||||
</button>
|
||||
<button class="delete-button" disabled={busy} on:click={() => remove(record)} aria-label={`${record.name} 삭제`}>삭제</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if selected && savedGrowth}
|
||||
<article>
|
||||
<span class="eyebrow">SAVED RESULT</span><h3 class="detail-title">{selected.name}</h3>
|
||||
<p class="hint">저장 당시 결과 · {new Date(selected.analyzedAt).toLocaleString('ko-KR')}</p>
|
||||
<div class="score-cards"><div><span>종합력</span><strong>{selected.overallPower.toLocaleString('ko-KR')}</strong></div><div><span>평균 점수</span><strong>{selected.result.expectedScore.toLocaleString('ko-KR')}</strong></div><div><span>이론상 고점</span><strong>{selected.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div></div>
|
||||
<h4>추천 스페셜 순서</h4>
|
||||
<ol class="order-cards">{#each selected.result.order as id, i}<li><span class="slot">{i + 1}</span><strong>{cardName(id)}</strong><span class="cooldown">쿨타임 −{selected.result.cooldownReductions[id]}%</span></li>{/each}</ol>
|
||||
<details><summary>당시 성장 상태·분석 설정</summary>
|
||||
<p>{selected.settings.cardIds.map((id, i) => `${cardName(id)} ${selected!.settings.awakenings[i]}개화`).join(' / ')}</p>
|
||||
<p>리더 의상 카드: {cardName(selected.settings.leaderOutfitCardId)}</p>
|
||||
<p>메모리 {savedGrowth.memoryCount}개 · 멤버 강화 {savedGrowth.memberPowerUpPercent}% · 활성 보드 {Object.values(savedGrowth.memberBoards).reduce((n, ids) => n + ids.length, 0)}개</p>
|
||||
<p>파란 노드 전체: {selected.settings.allBlueNodes?.map((v, i) => v ? `${i + 1}번` : '').filter(Boolean).join(', ') || '미사용'} · 기수 보너스 덮어쓰기: {selected.settings.generationBonusOverrides?.map((v) => v ?? '실제').join(' / ')} · 가창 보너스: {selected.settings.songScoreBonusOverride ?? '실제 보드'} </p>
|
||||
</details>
|
||||
<div class="compare-actions"><button class="primary" disabled={busy} on:click={compare}>현재 성장 상태와 비교</button>
|
||||
<button disabled={busy} on:click={showDetail}>당시 성장으로 상세 재분석</button></div>
|
||||
<p class="hint">곡·편성·의상·덮어쓰기·쿨타임 고정 조건을 유지합니다. 현재 보유 카드의 개화와 보드·연결 카드·메모리·멤버 강화를 적용합니다. 덮어쓴 항목의 실제 성장은 비교에 반영되지 않습니다.</p>
|
||||
{#if comparison}
|
||||
<div class="comparison-heading"><span class="eyebrow">GROWTH REPORT</span><h3>성장이 만든 점수 변화</h3></div>
|
||||
<p>양쪽 모두 현재 계산식과 악곡 데이터로 재최적화한 비교입니다. 위에 보관된 원본 점수와 다를 수 있습니다.</p>
|
||||
<div class="table-scroll"><table><thead><tr><th>항목</th><th>당시 성장</th><th>현재 성장</th><th>변화</th></tr></thead><tbody>
|
||||
{#each [ ['종합력', comparison.before.overallPower, comparison.after.overallPower], ['평균 점수', comparison.before.result.expectedScore, comparison.after.result.expectedScore], ['이론상 고점', comparison.before.result.theoreticalMaxScore, comparison.after.result.theoreticalMaxScore] ] as row}
|
||||
<tr><th>{row[0]}</th><td>{Number(row[1]).toLocaleString('ko-KR')}</td><td>{Number(row[2]).toLocaleString('ko-KR')}</td><td class:positive={Number(row[2]) > Number(row[1])} class:negative={Number(row[2]) < Number(row[1])}>{delta(Number(row[1]), Number(row[2]))}</td></tr>
|
||||
{/each}
|
||||
</tbody></table></div>
|
||||
<h4>성장 데이터 변화</h4>
|
||||
<div class="changes">{#each comparison.changes as change}<p><span aria-hidden="true">↗</span>{change}</p>{:else}<p>비교 대상의 성장 데이터 변화가 없습니다.</p>{/each}</div>
|
||||
<h4>현재 성장의 추천 배치</h4>
|
||||
<ol class="order-cards">{#each comparison.after.result.order as id, i}<li><span class="slot">{i + 1}</span><strong>{cardName(id)}</strong><span class="cooldown">쿨타임 −{comparison.after.result.cooldownReductions[id]}%</span></li>{/each}</ol>
|
||||
{/if}
|
||||
{#if detail}
|
||||
<h4>당시 성장으로 상세 재분석한 결과</h4>
|
||||
<p class="hint">노트별 그래프는 저장 용량을 줄이기 위해 보관하지 않습니다. 아래는 현재 계산식·악곡 데이터로 재계산한 결과이며 저장 원본 점수와 다를 수 있습니다.</p>
|
||||
<AnalysisResultView result={detail} durationSeconds={selected.durationSeconds} {cardName} instanceId={`history-${selected.id}`} />
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.history { margin-top: 24px; padding: 28px; border: 1px solid #303541; border-radius: 18px; background: #181d24; color: #e8ebf1; }
|
||||
.history-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; } .eyebrow { color: #e99cbd; font-size: 10px; font-weight: 800; letter-spacing: .16em; }
|
||||
h2 { font-size: 24px; margin: 8px 0 0; letter-spacing: -.04em; } h3 { margin: 8px 0; } h4 { font-size: 14px; color: #b9c2ce; margin: 22px 0 12px; }
|
||||
p { line-height: 1.65; color: #aeb7c5; } .intro { margin: 10px 0 24px; } .hint { font-size: 12px; color: #8f9aaa; }
|
||||
.count-badge { padding: 8px 12px; border: 1px solid #664253; border-radius: 20px; color: #efb1ce; background: #342530; white-space: nowrap; font-size: 12px; }
|
||||
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 14px; border: 1px solid #303744; background: #11161d; border-radius: 12px; }
|
||||
.actions > input { flex: 1; min-width: 180px; }
|
||||
button, input, .file-button { box-sizing: border-box; padding: 11px 14px; border: 1px solid #394353; border-radius: 8px; background: #202731; color: #dce2eb; font: inherit; font-size: 13px; }
|
||||
input { background: #11161d; } button, .file-button { cursor: pointer; transition: border-color .15s, background .15s; }
|
||||
button:hover:not(:disabled), .file-button:hover { border-color: #bd7899; background: #2e2935; }
|
||||
button:focus-visible, input:focus-visible, .file-button:focus-within { outline: 2px solid #f0a0c4; outline-offset: 3px; }
|
||||
button.primary { background: #e99abc; color: #241521; border-color: #e99abc; font-weight: 800; } button.primary:hover:not(:disabled) { background: #f5b1cf; }
|
||||
button:disabled, .disabled { opacity: .45; cursor: default; }
|
||||
.file-button { position: relative; display: inline-flex; align-items: center; overflow: hidden; } .file-button input { position: absolute; inset: 0; opacity: 0; width: 100%; cursor: pointer; }
|
||||
.empty { text-align: center; border: 1px dashed #414554; border-radius: 12px; padding: 36px 16px; margin-top: 20px; } .empty > span { display: block; font-size: 36px; color: #d08bab; margin-bottom: 14px; } .empty h3 { font-size: 17px; } .empty p { font-size: 13px; }
|
||||
.record-grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 12px; margin: 16px 0 0; }
|
||||
.record-grid > li { position: relative; min-width: 0; border: 1px solid #343d4a; border-radius: 13px; background: #121820; overflow: hidden; }
|
||||
.record-grid > li.selected { border-color: #dc92b4; box-shadow: inset 0 3px #e99abc; background: #211e2a; }
|
||||
button.record { width: 100%; height: 100%; padding: 20px; padding-bottom: 16px; text-align: left; display: flex; flex-direction: column; gap: 12px; border: 0; border-radius: 0; background: transparent; }
|
||||
.record-meta { display: flex; align-items: center; justify-content: space-between; width: 100%; color: #8e99a9; font-size: 11px; }
|
||||
.difficulty { padding: 4px 7px; border-radius: 5px; background: #392738; color: #f0acd0; letter-spacing: .06em; font-size: 9px; font-weight: 800; }
|
||||
.record-name { font-size: 18px; letter-spacing: -.03em; overflow-wrap: anywhere; } .song-name { color: #a6b0be; font-size: 12px; margin-top: -6px; overflow-wrap: anywhere; }
|
||||
.members { display: flex; flex-wrap: wrap; gap: 5px; margin: 3px 0 8px; } .members > span { padding: 4px 7px; background: #242b36; border-radius: 5px; color: #b9c3d2; font-size: 11px; }
|
||||
.record-scores { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; padding-top: 14px; border-top: 1px solid #343541; margin-top: auto; }
|
||||
.record-scores > span { display: grid; gap: 6px; } small { color: #9aa4b4; font-size: 11px; } .record-scores b { font-size: 20px; font-variant-numeric: tabular-nums; color: #edb0cd; letter-spacing: -.025em; }
|
||||
.open-label { display: flex; gap: 10px; margin-top: 9px; font-size: 11px; color: #c891af; }
|
||||
button.delete-button { position: absolute; bottom: 9px; right: 10px; padding: 7px 10px; font-size: 11px; color: #9ca6b5; background: transparent; border-color: transparent; }
|
||||
article { margin-top: 28px; border-top: 1px solid #394353; padding-top: 26px; } .detail-title { font-size: 23px; letter-spacing: -.03em; }
|
||||
.score-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 20px 0; } .score-cards > div { display: grid; gap: 10px; padding: 18px; background: #25212e; border: 1px solid #3b3044; border-radius: 10px; } .score-cards span { color: #b2a6bc; font-size: 12px; } .score-cards strong { color: #f2b0cf; font-size: clamp(18px, 2.1vw, 26px); font-variant-numeric: tabular-nums; }
|
||||
.order-cards { list-style: none; padding: 0; display: grid; gap: 7px; } .order-cards li { display: flex; align-items: center; gap: 12px; background: #121820; border: 1px solid #303946; padding: 10px 12px; border-radius: 9px; } .slot { display: grid; place-items: center; width: 26px; height: 26px; flex-shrink: 0; color: #261724; background: #dd97b9; font-weight: 800; border-radius: 7px; } .order-cards strong { font-size: 13px; } .cooldown { margin-left: auto; color: #a9b5c5; font-size: 11px; white-space: nowrap; }
|
||||
details { margin: 18px 0; border: 1px solid #333d4a; border-radius: 10px; padding: 14px; background: #141a22; } summary { cursor: pointer; font-size: 13px; color: #bbc6d4; } details p { font-size: 12px; }
|
||||
.compare-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; } .comparison-heading { margin-top: 30px; padding-top: 22px; border-top: 1px solid #394353; }
|
||||
.table-scroll { overflow-x: auto; border: 1px solid #343e4c; border-radius: 12px; } table { width: 100%; border-collapse: collapse; white-space: nowrap; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
th, td { padding: 16px; text-align: right; border-bottom: 1px solid #303846; } th:first-child { text-align: left; } thead { background: #232b36; color: #b7c3d3; font-size: 11px; } tbody { background: #131a22; } tbody tr:last-child > * { border-bottom: 0; } td:last-child { font-weight: 800; } td.positive { color: #89d5aa; background: #172920; } td.negative { color: #f1a4ae; background: #2c1f28; }
|
||||
.changes { display: grid; gap: 7px; } .changes p { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin: 0; background: #202630; border-radius: 8px; font-size: 12px; } .changes span { color: #d49abc; }
|
||||
[role=alert] { color: #f3a2ae; padding: 12px; border-radius: 8px; background: #31212b; } [role=status] { color: #a6d5bf; font-size: 13px; }
|
||||
@media(max-width: 600px) { .history { padding: 18px; } h2 { font-size: 20px; } .actions > input { flex-basis: 100%; min-width: 0; } .score-cards { grid-template-columns: 1fr; } .score-cards > div { display: flex; align-items: center; justify-content: space-between; padding: 14px; } .order-cards li { flex-wrap: wrap; gap: 8px; } .record-scores b { font-size: 22px; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user