feat: improve card catalog and add outfit effect filters
Publish GitHub Pages output / publish (push) Successful in 38s

This commit is contained in:
2026-10-07 17:36:22 +09:00
parent 1b94c781be
commit 89e9976ce9
16 changed files with 971 additions and 1100 deletions
+17 -1
View File
@@ -1,9 +1,25 @@
--- ---
updatedAt: 2026-10-07T03:27:00.000Z updatedAt: 2026-10-07T08:23:52.000Z
--- ---
## 2026년 10월 7일 ## 2026년 10월 7일
### 카드·의상 효과 메뉴 분리
`NEW` 카드 목록 상단에 ‘카드 / 의상 효과’ 서브 메뉴를 추가했습니다. ★4·★5 리더 의상을 발동 조건의 속성·기수, 강화 효과, 획득 카드 등급·이격, 멤버 기수로 필터링할 수 있습니다. 스탯 증가율과 스코어 서포트 수치를 각각 정렬하며, 두 효과가 함께 있는 의상도 각각 표시합니다. 강화 효과의 스탯은 마크와 이름을 함께 표시합니다. 여러 의상의 상세를 동시에 펼쳐 비교할 수 있고 필터·정렬 설정은 다음 방문에도 유지합니다. 카드 상세의 의상 효과에서 해당 의상으로 바로 이동해 상세를 펼칠 수 있습니다.
`IMPROVED` 카드와 의상 효과 화면의 제목·설명·개수 표시와 서브 메뉴 높이·위치를 통일했습니다. 의상 화면의 중복 제목·설명과 검색창을 제거하고 필터, 체크박스, 아이콘, 버튼과 목록 테두리를 같은 스타일로 맞춰 탭 전환 시 화면 상단이 움직이지 않도록 개선했습니다.
### 카드 목록 한 행 비교
`IMPROVED` 카드 목록에 이름·기수·이격·속성·주 스탯·액티브 확률·수치·쿨타임·지속시간·스페셜 수치·지속시간을 순서대로 표시합니다. 목록의 속성·주 스탯은 아이콘으로, 액티브 확률은 높음·중간으로 표시하며, 스페셜의 발동률 보너스와 조건부 표시도 함께 제공합니다. 스페셜 수치를 누르면 스코어 서포트 수치 기준으로 오름차순·내림차순 정렬할 수 있습니다. 명함·5개화 선택은 목록 위에서 전환하며, 모바일에서도 가로 스크롤로 같은 행을 비교할 수 있습니다. 행을 누르면 기존 상세 스킬 정보를 확인할 수 있습니다.
`IMPROVED` 카드 목록 필터를 보고 싶은 항목을 선택하는 방식으로 변경했습니다. 기본은 전체 표시이며, 큐트만 선택하면 큐트 카드만 표시합니다. 같은 그룹의 여러 선택은 함께 포함하고 다른 그룹의 조건은 동시에 적용합니다. 그룹의 선택을 모두 해제하면 해당 그룹은 전체 표시로 돌아가며, ‘필터 초기화’로 모든 조건을 해제할 수 있습니다. 선택한 필터는 다음 방문에도 유지합니다.
`IMPROVED` 카드 상세를 패시브·액티브·스페셜·의상 효과별 간결한 행으로 정리했습니다. 조건과 전체 스킬 설명을 유지하면서 여백과 반복되는 박스를 줄였습니다. 여러 카드의 상세를 동시에 펼쳐 비교할 수 있으며, 개별 접기와 ‘상세 모두 접기’를 지원합니다. 필터·정렬·개화 변경 시에도 펼친 카드의 상세 상태를 유지합니다.
`IMPROVED` 카드 목록의 열 제목을 액티브·스페셜 그룹으로 묶고 아이콘 열과 여백을 줄여, 일반적인 데스크톱 화면에서 모든 항목을 가로 스크롤 없이 확인할 수 있도록 개선했습니다. 모든 열이 화면 폭에 따라 균형 있게 늘어나며, 이름은 왼쪽에 나머지 제목·아이콘·수치는 가운데에 맞췄습니다. 조건부 표시는 수치 아래에 배치해 숫자의 정렬을 유지합니다. 긴 이름·기수·수치는 셀 안에서 줄바꿈하며, 좁은 화면의 상세 설명도 목록의 보이는 폭에 맞춥니다.
### 오션 체이스 이벤트 계산 ### 오션 체이스 이벤트 계산
`NEW` 이벤트 기간 동안 HOLOGRAM CIRCUS는 폴카, Lamy's Baribari Workout은 라미, Tokyo Shandy Rendez-vous는 리리카의 신규 수영복 ★5 카드를 편성하면 최종 스코어에 10% 보너스를 적용합니다. `NEW` 이벤트 기간 동안 HOLOGRAM CIRCUS는 폴카, Lamy's Baribari Workout은 라미, Tokyo Shandy Rendez-vous는 리리카의 신규 수영복 ★5 카드를 편성하면 최종 스코어에 10% 보너스를 적용합니다.
+15 -8
View File
@@ -5,6 +5,7 @@
import AiPage from '../src/routes/tools/ai/+page.svelte'; import AiPage from '../src/routes/tools/ai/+page.svelte';
import HomePage from '../src/routes/+page.svelte'; import HomePage from '../src/routes/+page.svelte';
import CardsPage from '../src/routes/cards/+page.svelte'; import CardsPage from '../src/routes/cards/+page.svelte';
import OutfitsPage from '../src/routes/cards/outfits/+page.svelte';
import AnalysisPage from '../src/routes/analysis/+page.svelte'; import AnalysisPage from '../src/routes/analysis/+page.svelte';
import RatingPage from '../src/routes/rating/+page.svelte'; import RatingPage from '../src/routes/rating/+page.svelte';
import CombinationPage from '../src/routes/combination/+page.svelte'; import CombinationPage from '../src/routes/combination/+page.svelte';
@@ -25,6 +26,7 @@
const routes = new Map([ const routes = new Map([
['/', HomePage], ['/', HomePage],
['/cards', CardsPage], ['/cards', CardsPage],
['/cards/outfits', OutfitsPage],
['/analysis', AnalysisPage], ['/analysis', AnalysisPage],
['/rating', RatingPage], ['/rating', RatingPage],
['/combination', CombinationPage], ['/combination', CombinationPage],
@@ -43,24 +45,27 @@
['/updates', UpdatesPage] ['/updates', UpdatesPage]
]); ]);
function pathFromHash() { function routeFromHash() {
const path = window.location.hash.replace(/^#/, '') || '/'; const route = window.location.hash.replace(/^#/, '') || '/';
if (path === '/tools/compare') return '/compare'; const url = new URL(route, window.location.href);
return routes.has(path) ? path : '/'; const pathname = url.pathname === '/tools/compare' ? '/compare' : url.pathname;
return routes.has(pathname) ? pathname + url.search + url.hash : '/';
} }
let currentPath = pathFromHash(); let currentRoute = routeFromHash();
let currentPath = new URL(currentRoute, window.location.href).pathname;
let CurrentPage = routes.get(currentPath) ?? HomePage; let CurrentPage = routes.get(currentPath) ?? HomePage;
let eventNow = Date.now(); let eventNow = Date.now();
let menuOpen = false; let menuOpen = false;
$: if (currentPath) menuOpen = false; $: if (currentPath) menuOpen = false;
$: showEventTab = Boolean(activeEventAt(events, eventNow)); $: showEventTab = Boolean(activeEventAt(events, eventNow));
setOfflinePage(currentPath); setOfflinePage(currentRoute);
function syncRoute() { function syncRoute() {
currentPath = pathFromHash(); currentRoute = routeFromHash();
currentPath = new URL(currentRoute, window.location.href).pathname;
CurrentPage = routes.get(currentPath) ?? HomePage; CurrentPage = routes.get(currentPath) ?? HomePage;
setOfflinePage(currentPath); setOfflinePage(currentRoute);
window.scrollTo({ top: 0 }); window.scrollTo({ top: 0 });
} }
@@ -126,7 +131,9 @@
</header> </header>
<AssistantConnection /> <AssistantConnection />
{#key currentRoute}
<svelte:component this={CurrentPage} /> <svelte:component this={CurrentPage} />
{/key}
<AnalyticsConsent pagePath={currentPath} /> <AnalyticsConsent pagePath={currentPath} />
+1 -21
View File
@@ -16,27 +16,7 @@ script = script.replaceAll('/icons/', './icons/');
await writeFile(scriptPath, script); await writeFile(scriptPath, script);
let style = await readFile(stylePath, 'utf8'); let style = await readFile(stylePath, 'utf8');
style = style.replaceAll('/icons/', '../icons/'); style = style.replace(/url\((['"]?)\/icons\//g, 'url($1../icons/');
style += `
.description-stat-icon { display: none !important; }
.stat-badge .stat-icon,
.filter-options label .stat-icon {
width: auto !important;
height: auto !important;
background: none !important;
mask: none !important;
-webkit-mask: none !important;
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.stat-badge .stat-icon-performance::after,
.filter-options label .stat-icon-performance::after { content: '퍼포먼스'; }
.stat-badge .stat-icon-technique::after,
.filter-options label .stat-icon-technique::after { content: '테크닉'; }
.stat-badge .stat-icon-sense::after,
.filter-options label .stat-icon-sense::after { content: '센스'; }
`;
await writeFile(stylePath, style); await writeFile(stylePath, style);
// 점수 채보는 app.js에 포함된다. 별도로 불러오는 시각화 채보는 보존한다. // 점수 채보는 app.js에 포함된다. 별도로 불러오는 시각화 채보는 보존한다.
@@ -0,0 +1,30 @@
<script lang="ts">
import CardsNav from './CardsNav.svelte';
export let active: 'cards' | 'outfits';
export let count: number;
export let total: number;
export let unit: '장' | '벌';
</script>
<header class="catalog-header">
<div class="heading">
<h1>카드 목록</h1>
<p class="total" aria-label={`${count} / ${total}${unit}`}>
<strong>{count}</strong>
{#if count === total}{unit}{:else}<span>/ {total}{unit}</span>{/if}
</p>
</div>
<p class="description">카드와 의상 효과를 한 행에서 비교하세요.</p>
</header>
<CardsNav {active} />
<style>
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
h1 { margin: 0; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
.description { margin: 10px 0 0; color: #969fb0; font-size: var(--page-description-size); line-height: 1.6; }
.total { flex-shrink: 0; margin: 0; padding: 9px 14px; border: 1px solid #303746; border-radius: 999px; color: #969fb0; background: #191f2b; font-size: 13px; line-height: 24px; font-variant-numeric: tabular-nums; }
.total strong { margin-right: 3px; color: #edf0f6; font-size: 18px; }
.total span { color: #969fb0; }
@media (max-width: 680px) { .total { padding: 6px 10px; } }
</style>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
export let active: 'cards' | 'outfits';
</script>
<nav aria-label="카드 목록 메뉴">
<a href="/cards" aria-current={active === 'cards' ? 'page' : undefined}>카드</a>
<a href="/cards/outfits" aria-current={active === 'outfits' ? 'page' : undefined}>의상 효과</a>
</nav>
<style>
nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
a { display: flex; flex: 1; align-items: center; justify-content: center; min-width: 0; min-height: 38px; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; line-height: 20px; text-align: center; text-decoration: none; }
a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
a:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
</style>
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { matchesCardFilter, normalizeCardFilter } from '../models/card-list.ts';
import { defaultPreferences } from '../models/default-preferences.ts';
import { createUserData } from '../models/user-data.ts';
import { parseUserData } from '../storage/browser-storage.ts';
test('기본 필터는 선택 없이 전체를 포함하며 한 번 선택하면 해당 항목만 포함한다', () => {
const defaults = defaultPreferences();
assert.deepEqual(defaults.types, []);
assert.equal(defaults.filterMode, 'include');
assert.equal(matchesCardFilter(defaults.types, '큐트'), true);
assert.equal(matchesCardFilter(defaults.types, '해피'), true);
assert.equal(matchesCardFilter(['큐트'], '큐트'), true);
assert.equal(matchesCardFilter(['큐트'], '해피'), false);
assert.equal(matchesCardFilter(['큐트', '해피'], '해피'), true);
assert.equal(matchesCardFilter(['큐트', '해피'], '퓨어'), false);
});
test('다른 그룹은 교집합으로 적용하고 마지막 선택을 해제하면 해당 그룹은 전체로 돌아간다', () => {
const cards = [
{ name: '큐트 센스', type: '큐트', stat: '센스' },
{ name: '큐트 테크닉', type: '큐트', stat: '테크닉' },
{ name: '해피 센스', type: '해피', stat: '센스' }
];
const filter = (types: string[], stats: string[]) => cards
.filter(card => matchesCardFilter(types, card.type) && matchesCardFilter(stats, card.stat))
.map(card => card.name);
assert.deepEqual(filter(['큐트'], ['센스']), ['큐트 센스']);
assert.deepEqual(filter(['큐트', '해피'], ['센스']), ['큐트 센스', '해피 센스']);
assert.deepEqual(filter([], ['센스']), ['큐트 센스', '해피 센스']);
assert.deepEqual(filter([], []), cards.map(card => card.name));
});
test('선택하지 않은 스킬 필터는 스킬이 없는 카드도 포함한다', () => {
assert.equal(matchesCardFilter([], null), true);
assert.equal(matchesCardFilter(['높음'], null), false);
assert.equal(matchesCardFilter(['조건부'], '일반'), false);
assert.equal(matchesCardFilter(['수영복'], '일반'), false);
});
test('기존 전체 선택은 미선택으로 전환하고 부분 선택 및 새 전체 선택은 보존한다', () => {
const options = ['큐트', '퓨어', '해피'];
assert.deepEqual(normalizeCardFilter(options, options, true), []);
assert.deepEqual(normalizeCardFilter(['큐트'], options, true), ['큐트']);
assert.deepEqual(normalizeCardFilter([], options, true), []);
assert.deepEqual(normalizeCardFilter(options, options), options);
assert.deepEqual(normalizeCardFilter(['큐트', '큐트', 'unknown'], options), ['큐트']);
});
test('백업 파서는 새 필터 방식과 선택 상태를 보존하며 기존 백업을 구별한다', () => {
const defaults = defaultPreferences();
const data = createUserData({ ...defaults, types: ['큐트'], stats: ['센스'], sortKey: 'specialEffect', sortDirection: 'desc' });
data.collection = { ownedCard: { owned: true, awakening: 3 } };
assert.deepEqual(parseUserData(JSON.stringify(data), defaults), data);
const { filterMode, ...oldPreferences } = data.preferences;
const old = parseUserData(JSON.stringify({ ...data, preferences: oldPreferences }), defaults);
assert.equal(old.preferences.filterMode, undefined);
assert.deepEqual(old.preferences.types, ['큐트']);
assert.deepEqual(old.collection, data.collection);
});
+45 -5
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { activeEffectPercent, sortCardsByActive, type CardSummary, type SkillLevel } from '../models/card-list.ts'; import { activeEffectPercent, specialScoreSupportPercent, skillEffectLabel, sortCardList, type CardSummary, type SkillLevel } from '../models/card-list.ts';
function skill(level: number, values: Partial<SkillLevel> = {}): SkillLevel { function skill(level: number, values: Partial<SkillLevel> = {}): SkillLevel {
return { return {
@@ -33,8 +33,8 @@ test('sorts numeric active values in both directions and keeps missing values la
card('fast', [skill(1, { coolTimeSeconds: 18 })]) card('fast', [skill(1, { coolTimeSeconds: 18 })])
]; ];
assert.deepEqual(sortCardsByActive(cards, 'coolTime', 'asc', 1).map(({ id }) => id), ['fast', 'slow', 'missing']); assert.deepEqual(sortCardList(cards, 'coolTime', 'asc', 1).map(({ id }) => id), ['fast', 'slow', 'missing']);
assert.deepEqual(sortCardsByActive(cards, 'coolTime', 'desc', 1).map(({ id }) => id), ['slow', 'fast', 'missing']); assert.deepEqual(sortCardList(cards, 'coolTime', 'desc', 1).map(({ id }) => id), ['slow', 'fast', 'missing']);
}); });
test('uses the selected skill level and preserves source order for ties', () => { test('uses the selected skill level and preserves source order for ties', () => {
@@ -44,11 +44,51 @@ test('uses the selected skill level and preserves source order for ties', () =>
card('same', [skill(1, { effect: '스코어가 90% UP' }), skill(2, { effect: '스코어가 100% UP' })]) card('same', [skill(1, { effect: '스코어가 90% UP' }), skill(2, { effect: '스코어가 100% UP' })])
]; ];
assert.deepEqual(sortCardsByActive(cards, 'effect', 'desc', 1).map(({ id }) => id), ['second', 'same', 'first']); assert.deepEqual(sortCardList(cards, 'effect', 'desc', 1).map(({ id }) => id), ['second', 'same', 'first']);
assert.deepEqual(sortCardsByActive(cards, 'effect', 'desc', 2).map(({ id }) => id), ['first', 'second', 'same']); assert.deepEqual(sortCardList(cards, 'effect', 'desc', 2).map(({ id }) => id), ['first', 'second', 'same']);
}); });
test('falls back to the active description when the separated effect is unavailable', () => { test('falls back to the active description when the separated effect is unavailable', () => {
assert.equal(activeEffectPercent(skill(1, { description: '10초 동안 스코어가 85% UP' })), 85); assert.equal(activeEffectPercent(skill(1, { description: '10초 동안 스코어가 85% UP' })), 85);
assert.equal(activeEffectPercent(skill(1, { effect: '라이프가 회복됩니다.' })), null); assert.equal(activeEffectPercent(skill(1, { effect: '라이프가 회복됩니다.' })), null);
}); });
test('표시 수치는 스코어와 스페셜 발동률 보너스를 함께 보존한다', () => {
assert.equal(skillEffectLabel(skill(1, { effect: '스코어가 85% UP' })), '85%');
assert.equal(skillEffectLabel(skill(1, { effect: '스코어 서포트 효과 145% 스킬 발동률이 55% UP' })), '145% · 발동 +55%');
assert.equal(skillEffectLabel(skill(2, { effect: '스코어가 [highlight]100[/highlight]% UP' })), '100%');
assert.equal(skillEffectLabel(undefined), '—');
});
test('분리된 효과가 없으면 조건·수치를 원문 그대로 보존하고 개화 마크업만 제거한다', () => {
const value = skill(2, { description: '라이프 1000 이상이면 10초 동안 스코어가 [highlight]100[/highlight]% UP' });
assert.equal(skillEffectLabel(value), '라이프 1000 이상이면 10초 동안 스코어가 100% UP');
assert.equal(activeEffectPercent(value), 100);
assert.equal(skillEffectLabel(skill(1, { effect: '라이프가 300 회복됩니다.' })), '라이프가 300 회복됩니다.');
});
test('스페셜은 액티브 유무와 발동률 보너스에 관계없이 스코어 서포트 수치로 정렬한다', () => {
const withBonus = card('with-bonus');
withBonus.details.special = [skill(1, { effect: '스코어 서포트 효과 120% 스킬 발동률이 55% UP' })];
const higherScore = card('higher-score', [skill(1, { effect: '스코어가 75% UP' })]);
higherScore.details.special = [skill(1, { effect: '스코어 서포트 효과 135%' })];
const cards = [card('missing'), higherScore, withBonus];
assert.deepEqual(sortCardList(cards, 'specialEffect', 'asc', 1).map(({ id }) => id), ['with-bonus', 'higher-score', 'missing']);
assert.deepEqual(sortCardList(cards, 'specialEffect', 'desc', 1).map(({ id }) => id), ['higher-score', 'with-bonus', 'missing']);
});
test('스페셜 정렬은 선택한 개화 수치에 따라 바뀌고 동률은 원래 순서를 유지한다', () => {
const first = card('first');
first.details.special = [skill(1, { effect: '스코어 서포트 효과 120%' }), skill(2, { effect: '스코어 서포트 효과 160%' })];
const second = card('second');
second.details.special = [skill(1, { effect: '스코어 서포트 효과 135%' }), skill(2, { effect: '스코어 서포트 효과 145%' })];
const tie = { ...second, id: 'tie' };
assert.deepEqual(sortCardList([first, second, tie], 'specialEffect', 'desc', 1).map(({ id }) => id), ['second', 'tie', 'first']);
assert.deepEqual(sortCardList([first, second, tie], 'specialEffect', 'desc', 2).map(({ id }) => id), ['first', 'second', 'tie']);
});
test('스페셜 설명의 조건과 발동률 수치는 정렬값으로 사용하지 않는다', () => {
assert.equal(specialScoreSupportPercent(skill(2, { description: '라이프 1000 이상이면 10초 동안 스코어 서포트 효과 [highlight]145[/highlight]% 스킬 발동률이 55% UP' })), 145);
assert.equal(specialScoreSupportPercent(skill(1, { effect: '스킬 발동률이 55% UP' })), null);
assert.equal(specialScoreSupportPercent(undefined), null);
});
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import type { CardSummary } from '../models/card-list.ts';
import { generationMemberships, outfitSortValue, parseOutfit, sortOutfits } from '../models/outfit-list.ts';
const cards: CardSummary[] = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')).cards;
const outfits = cards.map(parseOutfit).filter(entry => entry !== null);
test('현재 4·5성 의상의 모든 효과와 반복된 조건을 누락 없이 해석한다', () => {
assert.equal(outfits.length, cards.filter(card => (card.rarity === 4 || card.rarity === 5) && card.details.costume).length);
assert.ok(outfits.length > 100);
assert.deepEqual(outfits.filter(outfit => !outfit.complete).map(outfit => outfit.name), []);
assert.ok(outfits.some(outfit => outfit.card.rarity === 4));
assert.ok(outfits.some(outfit => outfit.card.rarity === 5));
const mixed = outfits.find(outfit => outfit.conditionTypes.includes('큐트') && outfit.effects.length === 2);
assert.ok(mixed);
assert.equal(mixed.conditions.length, 1);
assert.deepEqual(mixed.effects.map(effect => effect.condition), [mixed.conditions[0], mixed.conditions[0]]);
assert.equal(outfitSortValue(mixed, 'support'), 25);
assert.ok(mixed.effects.some(effect => effect.kind !== '스코어 서포트'));
});
test('속성 조건은 획득 카드 속성과 구분하고 기수 명칭을 정규화한다', () => {
const source = outfits.find(entry => entry.conditionTypes.includes('큐트'))!;
const outfit = parseOutfit({ ...source.card, type: '퓨어' });
assert.ok(outfit);
assert.deepEqual(outfit.conditionTypes, ['큐트']);
assert.ok(outfits.some(entry => entry.conditionGenerations.includes('게이머즈')));
assert.ok(outfits.some(entry => entry.conditionGenerations.includes('ID 1기생')));
assert.deepEqual(generationMemberships('1기생 / GAMERS'), ['1기생', '게이머즈']);
});
test('파라미터와 서포트를 따로 정렬하고 없는 값은 양방향 모두 마지막에 둔다', () => {
const parameter = outfits.find(outfit => outfitSortValue(outfit, 'parameter') === 50 && outfitSortValue(outfit, 'support') === null);
const support = outfits.find(outfit => outfitSortValue(outfit, 'parameter') === null && outfitSortValue(outfit, 'support') === 60);
const mixed = outfits.find(outfit => outfitSortValue(outfit, 'parameter') === 30 && outfitSortValue(outfit, 'support') === 25);
assert.ok(parameter && support && mixed);
const input = [parameter, support, mixed];
assert.deepEqual(sortOutfits(input, 'parameter', 'asc'), [mixed, parameter, support]);
assert.deepEqual(sortOutfits(input, 'parameter', 'desc'), [parameter, mixed, support]);
assert.deepEqual(sortOutfits(input, 'support', 'desc'), [support, mixed, parameter]);
assert.deepEqual(sortOutfits(input, 'support', 'asc'), [mixed, support, parameter]);
assert.deepEqual(input, [parameter, support, mixed]);
});
test('미지원 설명은 원문을 보존하고 부분 해석한 수치로 정렬하지 않는다', () => {
const card = cards.find(card => card.rarity === 5 && card.details.costume)!;
const description = '전원의 퍼포먼스가 130% UP 추가 효과는 미확인';
const outfit = parseOutfit({ ...card, details: { ...card.details, costume: { name: '미확인', description } } });
assert.ok(outfit);
assert.equal(outfit.complete, false);
assert.equal(outfit.description, description);
assert.equal(outfitSortValue(outfit, 'parameter'), null);
assert.equal(parseOutfit({ ...card, rarity: 3 }), null);
});
+36 -4
View File
@@ -3,6 +3,17 @@ import type { CardListSortKey, SortDirection } from './user-data.ts';
export type CardType = '큐트' | '퓨어' | '해피'; export type CardType = '큐트' | '퓨어' | '해피';
export type HighestStat = '퍼포먼스' | '테크닉' | '센스'; export type HighestStat = '퍼포먼스' | '테크닉' | '센스';
export function matchesCardFilter(selected: readonly string[], value: string | null): boolean {
return selected.length === 0 || (value !== null && selected.includes(value));
}
export function normalizeCardFilter<T extends string>(
selected: readonly string[], options: readonly T[], legacy = false
): T[] {
const valid = options.filter((option) => selected.includes(option));
return legacy && valid.length === options.length ? [] : valid;
}
export type SkillLevel = { export type SkillLevel = {
level: number; level: number;
description: string; description: string;
@@ -40,15 +51,36 @@ export function skillAtLevel(
} }
export function activeEffectPercent(skill: SkillLevel | undefined): number | null { export function activeEffectPercent(skill: SkillLevel | undefined): number | null {
const match = (skill?.effect || skill?.description)?.match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/); const match = skillEffectText(skill).match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : null; return match ? Number(match[1]) : null;
} }
export function activeSortValue( export function specialScoreSupportPercent(skill: SkillLevel | undefined): number | null {
const match = skillEffectText(skill).match(/스코어\s+서포트\s+효과\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : null;
}
export function skillEffectText(skill: SkillLevel | undefined): string {
return (skill?.effect || skill?.description || '')
.replace(/\[\/?(?:highlight|attribute)(?:=[^\]]+)?\]/g, '')
.trim();
}
export function skillEffectLabel(skill: SkillLevel | undefined): string {
const text = skillEffectText(skill);
const scoreOnly = text.match(/^스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%(?:\s+UP)?(?:\s+스킬 발동률이\s*(\d+(?:\.\d+)?)%\s+UP)?$/);
if (!scoreOnly) return text || '—';
return `${scoreOnly[1]}%${scoreOnly[2] ? ` · 발동 +${scoreOnly[2]}%` : ''}`;
}
export function cardListSortValue(
card: CardSummary, card: CardSummary,
sortKey: CardListSortKey, sortKey: CardListSortKey,
selectedLevel: number selectedLevel: number
): number | null { ): number | null {
if (sortKey === 'specialEffect') {
return specialScoreSupportPercent(skillAtLevel(card.details.special, selectedLevel));
}
const active = skillAtLevel(card.details.active, selectedLevel); const active = skillAtLevel(card.details.active, selectedLevel);
if (!active) return null; if (!active) return null;
@@ -62,7 +94,7 @@ export function activeSortValue(
return typeof value === 'number' && Number.isFinite(value) ? value : null; return typeof value === 'number' && Number.isFinite(value) ? value : null;
} }
export function sortCardsByActive( export function sortCardList(
cards: readonly CardSummary[], cards: readonly CardSummary[],
sortKey: CardListSortKey | null, sortKey: CardListSortKey | null,
sortDirection: SortDirection, sortDirection: SortDirection,
@@ -72,7 +104,7 @@ export function sortCardsByActive(
const direction = sortDirection === 'asc' ? 1 : -1; const direction = sortDirection === 'asc' ? 1 : -1;
return cards return cards
.map((card, index) => ({ card, index, value: activeSortValue(card, sortKey, selectedLevel) })) .map((card, index) => ({ card, index, value: cardListSortValue(card, sortKey, selectedLevel) }))
.sort((left, right) => { .sort((left, right) => {
if (left.value === null) return right.value === null ? left.index - right.index : 1; if (left.value === null) return right.value === null ? left.index - right.index : 1;
if (right.value === null) return -1; if (right.value === null) return -1;
+8 -8
View File
@@ -1,15 +1,15 @@
import cardSummary from '$lib/data/cards-summary.json';
import type { CardListPreferences } from './user-data'; import type { CardListPreferences } from './user-data';
export function defaultPreferences(): CardListPreferences { export function defaultPreferences(): CardListPreferences {
return { return {
types: ['큐트', '퓨어', '해피'], filterMode: 'include',
stats: ['퍼포먼스', '테크닉', '센스'], types: [],
activeConditions: ['일반', '조건부'], stats: [],
specialConditions: ['일반', '조건부'], activeConditions: [],
activeProbabilities: ['높음', '중간'], specialConditions: [],
cardVariants: ['일반', ...new Set(cardSummary.cards.filter((card) => card.rarity === 5).map((card) => card.versionName).filter(Boolean))], activeProbabilities: [],
generations: [...new Set(cardSummary.cards.filter((card) => card.rarity === 5).map((card) => card.generation).filter(Boolean))], cardVariants: [],
generations: [],
sortKey: null, sortKey: null,
sortDirection: 'asc', sortDirection: 'asc',
skillLevel: 1 skillLevel: 1
+65
View File
@@ -0,0 +1,65 @@
import type { CardSummary } from './card-list.ts';
import { plainSkillText } from '../engine/skill-condition.ts';
export const outfitEffectOptions = ['전체 파라미터', '퍼포먼스', '테크닉', '센스', '스코어 서포트'] as const;
export type OutfitEffectKind = typeof outfitEffectOptions[number];
export type OutfitEffect = { condition: string; target: string; kind: OutfitEffectKind; value: number };
export type OutfitEntry = {
card: CardSummary;
name: string;
description: string;
effects: OutfitEffect[];
conditions: string[];
conditionTypes: string[];
conditionGenerations: string[];
complete: boolean;
};
export type OutfitSortKey = 'parameter' | 'support';
export function generationLabel(value: string): string {
return value.replace(/^hololive EN /, '').replace(/^hololive ID /, 'ID ')
.replace(/^hololive /, '').replace(/^DEV_IS /, '').replace(/GAMERS/g, '게이머즈');
}
export function generationMemberships(value: string): string[] {
return value.split(/\s*\/\s*/).filter(Boolean).map(generationLabel);
}
export function parseOutfit(card: CardSummary): OutfitEntry | null {
const outfit = card.details.costume;
if (!outfit || (card.rarity !== 4 && card.rarity !== 5)) return null;
const description = plainSkillText(outfit.description);
const effects: OutfitEffect[] = [];
const pattern = /(전원|자신)의\s*(전체 파라미터|퍼포먼스|테크닉|센스|스코어 서포트 효과)(?:가|이)?\s*(\d+(?:\.\d+)?)%(?:\s*UP)?/g;
let cursor = 0;
let complete = true;
for (const match of description.matchAll(pattern)) {
const condition = description.slice(cursor, match.index).trim();
if (condition && !/^.+?\s*\d+명 이상이면$/.test(condition)) complete = false;
effects.push({ condition, target: match[1], kind: match[2] === '스코어 서포트 효과' ? '스코어 서포트' : match[2] as OutfitEffectKind, value: Number(match[3]) });
cursor = match.index + match[0].length;
}
complete = complete && effects.length > 0 && description.slice(cursor).trim() === '';
const conditions = [...new Set(effects.map(effect => effect.condition).filter(Boolean))];
const conditionTypes = conditions.flatMap(condition => condition.match(/^(큐트|퓨어|해피)\s*타입\s*\d+명 이상이면$/)?.[1] ?? []);
const conditionGenerations = conditions.filter(condition => !/^(큐트|퓨어|해피)\s*타입/.test(condition))
.flatMap(condition => {
const match = condition.match(/^(.+?)(?:\(이\)가)?\s*\d+명 이상이면$/);
return match ? [generationLabel(match[1].trim())] : [];
});
return { card, name: outfit.name, description, effects, conditions, conditionTypes, conditionGenerations, complete };
}
export function outfitSortValue(outfit: OutfitEntry, key: OutfitSortKey): number | null {
if (!outfit.complete) return null;
const values = outfit.effects.filter(effect => key === 'support' ? effect.kind === '스코어 서포트' : effect.kind !== '스코어 서포트').map(effect => effect.value);
return values.length ? Math.max(...values) : null;
}
export function sortOutfits(outfits: readonly OutfitEntry[], key: OutfitSortKey | null, direction: 'asc' | 'desc'): OutfitEntry[] {
if (!key) return [...outfits];
return outfits.map((outfit, index) => ({ outfit, index, value: outfitSortValue(outfit, key) }))
.sort((a, b) => a.value === null ? (b.value === null ? a.index - b.index : 1)
: b.value === null ? -1 : (a.value - b.value) * (direction === 'asc' ? 1 : -1) || a.index - b.index)
.map(entry => entry.outfit);
}
+2 -1
View File
@@ -12,11 +12,12 @@ export type SavedDeck = {
updatedAt: string; updatedAt: string;
}; };
export type CardListSortKey = 'coolTime' | 'probability' | 'duration' | 'effect'; export type CardListSortKey = 'coolTime' | 'probability' | 'duration' | 'effect' | 'specialEffect';
export type SortDirection = 'asc' | 'desc'; export type SortDirection = 'asc' | 'desc';
export type CardSkillLevel = 1 | 2; export type CardSkillLevel = 1 | 2;
export type CardListPreferences = { export type CardListPreferences = {
filterMode?: 'include';
types: string[]; types: string[];
stats: string[]; stats: string[];
activeConditions: string[]; activeConditions: string[];
+2 -1
View File
@@ -19,7 +19,7 @@ function finiteNumber(value: unknown, fallback = 0): number {
} }
function cardListSortKey(value: unknown, fallback: CardListSortKey | null): CardListSortKey | null { function cardListSortKey(value: unknown, fallback: CardListSortKey | null): CardListSortKey | null {
return value === 'coolTime' || value === 'probability' || value === 'duration' || value === 'effect' return value === 'coolTime' || value === 'probability' || value === 'duration' || value === 'effect' || value === 'specialEffect'
? value ? value
: fallback; : fallback;
} }
@@ -90,6 +90,7 @@ export function parseUserData(text: string, fallbackPreferences: CardListPrefere
boardCards: parseBoardCards(value.boardCards), boardCards: parseBoardCards(value.boardCards),
savedDecks: value.savedDecks as UserData['savedDecks'], savedDecks: value.savedDecks as UserData['savedDecks'],
preferences: { preferences: {
...(preferences.filterMode === 'include' ? { filterMode: 'include' as const } : {}),
types: stringArray(preferences.types, fallbackPreferences.types), types: stringArray(preferences.types, fallbackPreferences.types),
stats: stringArray(preferences.stats, fallbackPreferences.stats), stats: stringArray(preferences.stats, fallbackPreferences.stats),
activeConditions: stringArray(preferences.activeConditions, fallbackPreferences.activeConditions), activeConditions: stringArray(preferences.activeConditions, fallbackPreferences.activeConditions),
+66
View File
@@ -0,0 +1,66 @@
html:has(.card-catalog) { scrollbar-gutter: stable; }
body:has(.card-catalog) {
color-scheme: dark;
background:
radial-gradient(circle at 8% 0%, rgb(230 79 143 / 14%), transparent 28rem),
radial-gradient(circle at 94% 4%, rgb(65 111 209 / 13%), transparent 30rem),
#0d1119;
}
.card-catalog {
width: min(var(--page-max-width), calc(100% - var(--page-gutter)));
margin: 0 auto;
padding: var(--page-content-top) 0 80px;
}
.card-catalog .filters {
margin-bottom: 20px;
padding: 22px 24px 24px;
border: 1px solid #2b3240;
border-radius: 20px;
background: #161c27;
}
.card-catalog .filter-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 19px; }
.card-catalog .filter-header h2 { margin: 0; font-size: 17px; letter-spacing: -.025em; }
.card-catalog .filter-guide { margin: 8px 0 0; color: #969fb0; font-size: 13px; line-height: 1.5; }
.card-catalog .filter-note { margin: 8px 0 18px; color: #969fb0; font-size: 12px; line-height: 1.5; }
.card-catalog .filter-status { display: inline-block; margin-left: 4px; color: #969fb0; font-size: 11px; font-weight: 500; }
.card-catalog .filter-actions { display: flex; gap: 8px; }
.card-catalog .filter-action { flex-shrink: 0; white-space: nowrap; padding: 7px 12px; border: 1px solid #343c4b; border-radius: 9px; color: #b5bdcb; background: #1d2430; font: inherit; font-size: 14px; font-weight: 700; line-height: 20px; cursor: pointer; transition: border-color 150ms ease, color 150ms ease, background 150ms ease; }
.card-catalog .filter-action:hover:not(:disabled) { border-color: #c85b8b; color: #f08db7; background: #2a1c27; }
.card-catalog .filter-action:disabled { opacity: .42; cursor: default; }
.card-catalog .filter-action:focus-visible { outline: 2px solid #ed8bb5; outline-offset: 3px; }
.card-catalog .filter-groups { display: grid; grid-template-columns: repeat(var(--filter-columns, 6), minmax(0, 1fr)); gap: 20px; }
.card-catalog .generation-filter { grid-column: 1 / -1; }
.card-catalog fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
.card-catalog legend { margin-bottom: 10px; padding: 0; color: #969fb0; font-size: 14px; font-weight: 750; }
.card-catalog .filter-options { display: flex; flex-wrap: wrap; gap: 8px; }
.card-catalog fieldset:not(.generation-filter) .filter-options { flex-direction: column; align-items: stretch; }
.card-catalog .filter-options label { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 6px 11px 6px 8px; border: 1px solid #303746; border-radius: 10px; color: #a4adbc; background: #181e29; font-size: 13px; font-weight: 700; line-height: 20px; cursor: pointer; user-select: none; transition: border-color 150ms ease, color 150ms ease, background 150ms ease; }
.card-catalog .filter-options label:hover { border-color: #465064; background: #202735; }
.card-catalog .filter-options label.checked { border-color: #a94c75; color: #f08db7; background: #2a1b26; }
.card-catalog .filter-options input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
.card-catalog .checkmark { display: grid; flex-shrink: 0; width: 18px; height: 18px; place-items: center; border: 1px solid #485163; border-radius: 6px; color: transparent; background: #111722; font-size: 13px; line-height: 1; }
.card-catalog label.checked .checkmark { border-color: #ef5596; color: #fff; background: #ef5596; }
.card-catalog .filter-options input:focus-visible + .checkmark { outline: 2px solid #ed8bb5; outline-offset: 2px; }
.card-catalog .filter-type-icon { width: 24px; height: 24px; object-fit: contain; }
.card-catalog .filter-options .stat-icon { width: 19px; height: 19px; }
.card-catalog .chevron { position: relative; z-index: 2; width: 10px; height: 10px; justify-self: end; border-right: 2px solid #737d8f; border-bottom: 2px solid #737d8f; transform: rotate(45deg) translate(-2px, -2px); transition: transform 180ms ease, border-color 180ms ease; }
.card-catalog .expanded .chevron { border-color: #df4b8a; transform: rotate(225deg) translate(-1px, -1px); }
.card-catalog .list-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 10px; color: #aab3c3; font-size: 13px; }
.card-catalog .list-toolbar p { margin: 0; line-height: 1.6; }
.card-catalog .scroll-hint { display: none; margin: 0 0 10px; color: #969fb0; font-size: 12px; }
.card-catalog .table-panel { container-type: inline-size; overflow-x: auto; overflow-y: hidden; border: 1px solid #2b3240; border-radius: 18px; background: #161b25; }
@media (max-width: 1100px) { .card-catalog .filter-groups { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 1000px) { .card-catalog .scroll-hint { display: block; } }
@media (max-width: 680px) {
.card-catalog { width: calc(100% - 24px); padding-bottom: 48px; }
.card-catalog .filters { padding: 18px; border-radius: 18px; }
.card-catalog .filter-header { align-items: flex-start; flex-wrap: wrap; }
.card-catalog .filter-groups { grid-template-columns: 1fr; gap: 18px; }
.card-catalog fieldset:not(.generation-filter) .filter-options { flex-direction: row; }
.card-catalog .list-toolbar { align-items: flex-start; flex-direction: column; }
}
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import CardCatalogHeader from '$lib/components/CardCatalogHeader.svelte';
import '$lib/styles/card-catalog.css';
import cardSummary from '$lib/data/cards-summary.json';
import { matchesCardFilter, type CardSummary } from '$lib/models/card-list';
import { generationLabel, generationMemberships, outfitEffectOptions, outfitSortValue, parseOutfit, sortOutfits, type OutfitEntry, type OutfitSortKey } from '$lib/models/outfit-list';
const outfits = (cardSummary.cards as CardSummary[]).map(parseOutfit).filter((entry): entry is OutfitEntry => entry !== null);
const storageKey = 'holodori:outfit-list:v1';
const typeOptions = ['큐트', '퓨어', '해피'];
const typeIcons: Record<string, string> = { 큐트: '/icons/types/cute.webp', 퓨어: '/icons/types/pure.webp', 해피: '/icons/types/happy.webp' };
const conditionOptions = ['일반', '조건부', ...(outfits.some(outfit => !outfit.complete) ? ['확인 필요'] : [])];
const rarityOptions = ['★5', '★4'];
const variantOptions = ['일반', ...new Set(outfits.map(outfit => outfit.card.versionName).filter(Boolean))];
const conditionGenerationOptions = [...new Set(outfits.flatMap(outfit => outfit.conditionGenerations))];
const memberGenerationOptions = [...new Set(outfits.flatMap(outfit => generationMemberships(outfit.card.generation)))];
const statIcons: Record<string, string> = { 퍼포먼스: 'performance', 테크닉: 'technique', 센스: 'sense' };
let selectedTypes: string[] = [];
let selectedEffects: string[] = [];
let selectedConditions: string[] = [];
let selectedRarities: string[] = [];
let selectedVariants: string[] = [];
let selectedConditionGenerations: string[] = [];
let selectedMemberGenerations: string[] = [];
let sortKey: OutfitSortKey | null = null;
let sortDirection: 'asc' | 'desc' = 'asc';
let expandedIds: string[] = [];
let ready = false;
function conditionStatus(outfit: OutfitEntry) {
return !outfit.complete ? '확인 필요' : outfit.conditions.length ? '조건부' : '일반';
}
function matchesAny(selected: string[], values: readonly string[]) {
return selected.length === 0 || values.some(value => selected.includes(value));
}
$: filteredOutfits = sortOutfits(outfits.filter(outfit =>
matchesAny(selectedTypes, outfit.conditionTypes)
&& matchesAny(selectedEffects, outfit.effects.map(effect => effect.kind))
&& matchesCardFilter(selectedConditions, conditionStatus(outfit))
&& matchesCardFilter(selectedRarities, `★${outfit.card.rarity}`)
&& matchesCardFilter(selectedVariants, outfit.card.versionName || '일반')
&& matchesAny(selectedConditionGenerations, outfit.conditionGenerations)
&& matchesAny(selectedMemberGenerations, generationMemberships(outfit.card.generation))
), sortKey, sortDirection);
$: hasFilters = Boolean(selectedTypes.length || selectedEffects.length || selectedConditions.length || selectedRarities.length || selectedVariants.length || selectedConditionGenerations.length || selectedMemberGenerations.length);
$: settings = { selectedTypes, selectedEffects, selectedConditions, selectedRarities, selectedVariants, selectedConditionGenerations, selectedMemberGenerations, sortKey, sortDirection };
$: if (ready) {
try { localStorage.setItem(storageKey, JSON.stringify(settings)); } catch {}
}
onMount(() => {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
const valid = (value: unknown, options: readonly string[]) => Array.isArray(value) ? options.filter(option => value.includes(option)) : [];
selectedTypes = valid(saved.selectedTypes, typeOptions);
selectedEffects = valid(saved.selectedEffects, outfitEffectOptions);
selectedConditions = valid(saved.selectedConditions, conditionOptions);
selectedRarities = valid(saved.selectedRarities, rarityOptions);
selectedVariants = valid(saved.selectedVariants, variantOptions);
selectedConditionGenerations = valid(saved.selectedConditionGenerations, conditionGenerationOptions);
selectedMemberGenerations = valid(saved.selectedMemberGenerations, memberGenerationOptions);
sortKey = saved.sortKey === 'parameter' || saved.sortKey === 'support' ? saved.sortKey : null;
sortDirection = saved.sortDirection === 'desc' ? 'desc' : 'asc';
}
} catch {}
const requestedId = $page.url.searchParams.get('outfit');
if (requestedId && outfits.some(outfit => outfit.card.id === requestedId)) {
resetFilters();
expandedIds = [requestedId];
void tick().then(() => document.getElementById('outfit-' + requestedId)?.scrollIntoView({ block: 'center' }));
}
ready = true;
});
function resetFilters() {
selectedTypes = []; selectedEffects = []; selectedConditions = []; selectedRarities = [];
selectedVariants = []; selectedConditionGenerations = []; selectedMemberGenerations = [];
}
function toggleOutfit(id: string) {
expandedIds = expandedIds.includes(id) ? expandedIds.filter(value => value !== id) : [...expandedIds, id];
}
function toggleSort(key: OutfitSortKey) {
if (sortKey === key) sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
else { sortKey = key; sortDirection = 'asc'; }
}
function sortLabel(key: OutfitSortKey, label: string, currentKey: OutfitSortKey | null, direction: 'asc' | 'desc') {
return `${label} ${currentKey !== key ? '오름차순 정렬' : direction === 'asc' ? '내림차순으로 변경' : '오름차순으로 변경'}`;
}
function valueLabel(outfit: OutfitEntry, key: OutfitSortKey) {
const value = outfitSortValue(outfit, key);
return value === null ? '—' : `${value}%`;
}
function shortCondition(value: string) {
return generationLabel(value).replace(/\(이\)가/g, '').replace(/이상이면$/, '이상');
}
</script>
<svelte:head>
<title>의상 효과 · 갤산기</title>
<meta name="description" content="홀로드림스 4·5성 의상의 발동 조건과 파라미터·스코어 서포트 효과를 필터링하고 비교합니다." />
</svelte:head>
<main class="card-catalog">
<CardCatalogHeader active="outfits" count={filteredOutfits.length} total={outfits.length} unit="벌" />
<section class="filters" aria-labelledby="outfit-filter-heading">
<div class="filter-header"><div><h2 id="outfit-filter-heading">필터</h2><p class="filter-guide">보고 싶은 항목을 선택하세요. 선택하지 않은 그룹은 전체를 표시합니다.</p></div><button type="button" class="filter-action" onclick={() => resetFilters()} disabled={!hasFilters}>필터 초기화</button></div>
<p class="filter-note">조건 속성·기수는 의상 발동에 필요한 편성 조건입니다.</p>
<div class="filter-groups" style="--filter-columns: 5">
<fieldset><legend>조건 속성 <span class="filter-status">{selectedTypes.length ? `${selectedTypes.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each typeOptions as value}<label class:checked={selectedTypes.includes(value)}><input type="checkbox" bind:group={selectedTypes} {value} aria-label={value} /><span class="checkmark" aria-hidden="true">✓</span><img class="filter-type-icon" src={typeIcons[value]} alt="" aria-hidden="true" title={value} /><span>{value}</span></label>{/each}</div></fieldset>
<fieldset><legend>강화 효과 <span class="filter-status">{selectedEffects.length ? `${selectedEffects.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each outfitEffectOptions as value}<label class:checked={selectedEffects.includes(value)}><input type="checkbox" bind:group={selectedEffects} {value} /><span class="checkmark" aria-hidden="true">✓</span>{#if statIcons[value]}<span class={"stat-icon " + statIcons[value]} aria-hidden="true" title={value}></span>{/if}<span>{value}</span></label>{/each}</div></fieldset>
<fieldset><legend>발동 조건 <span class="filter-status">{selectedConditions.length ? `${selectedConditions.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each conditionOptions as value}<label class:checked={selectedConditions.includes(value)}><input type="checkbox" bind:group={selectedConditions} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
<fieldset><legend>획득 카드 등급 <span class="filter-status">{selectedRarities.length ? `${selectedRarities.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each rarityOptions as value}<label class:checked={selectedRarities.includes(value)}><input type="checkbox" bind:group={selectedRarities} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
<fieldset><legend>획득 카드 이격 <span class="filter-status">{selectedVariants.length ? `${selectedVariants.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each variantOptions as value}<label class:checked={selectedVariants.includes(value)}><input type="checkbox" bind:group={selectedVariants} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
<fieldset class="generation-filter"><legend>조건 기수 <span class="filter-status">{selectedConditionGenerations.length ? `${selectedConditionGenerations.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each conditionGenerationOptions as value}<label class:checked={selectedConditionGenerations.includes(value)}><input type="checkbox" bind:group={selectedConditionGenerations} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
<fieldset class="generation-filter"><legend>멤버 기수 <span class="filter-status">{selectedMemberGenerations.length ? `${selectedMemberGenerations.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each memberGenerationOptions as value}<label class:checked={selectedMemberGenerations.includes(value)}><input type="checkbox" bind:group={selectedMemberGenerations} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
</div>
</section>
<div class="list-toolbar"><p>수치는 조건 충족 시 기준입니다. 행을 눌러 여러 의상의 전체 설명을 함께 확인할 수 있습니다.</p><button type="button" class="filter-action" disabled={!expandedIds.length} onclick={() => expandedIds = []}>상세 모두 접기</button></div>
<p class="scroll-hint">목록을 좌우로 밀어 모든 항목을 확인할 수 있습니다.</p>
<section class="table-panel" aria-label="의상 효과 목록">
<div class="list-header"><span>멤버</span><span>기수</span><span>의상명</span><span>획득 카드</span><span>발동 조건</span><span>대상</span><span>강화 효과</span><button type="button" class:active={sortKey === 'parameter'} aria-label={sortLabel('parameter', '스탯 증가율', sortKey, sortDirection)} onclick={() => toggleSort('parameter')}><span>스탯<br />증가율</span><span aria-hidden="true">{sortKey === 'parameter' ? sortDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button><button type="button" class:active={sortKey === 'support'} aria-label={sortLabel('support', '스코어 서포트 수치', sortKey, sortDirection)} onclick={() => toggleSort('support')}><span>스코어<br />서포트</span><span aria-hidden="true">{sortKey === 'support' ? sortDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button><span></span></div>
{#if !filteredOutfits.length}<p class="empty" role="status">필터 조건에 맞는 의상이 없습니다.</p>{/if}
{#each filteredOutfits as outfit (outfit.card.id)}
{@const expanded = expandedIds.includes(outfit.card.id)}
<article class="outfit-entry" class:expanded id={'outfit-' + outfit.card.id}>
<div class="outfit-summary">
<button class="expand-button" type="button" aria-expanded={expanded} aria-controls={'outfit-detail-' + outfit.card.id} aria-label={`${outfit.card.name} ${outfit.name} 상세 정보 ${expanded ? '접기' : '펼치기'}`} onclick={() => toggleOutfit(outfit.card.id)}></button>
<span class="cell name">{outfit.card.name}</span>
<span class="cell generation">{generationLabel(outfit.card.generation)}</span>
<span class="cell outfit-name">{outfit.name}</span>
<span class="cell stack"><strong>★{outfit.card.rarity}</strong><span class="variant">{outfit.card.versionName || '일반'}</span></span>
<span class="cell stack condition">{#if !outfit.complete}<span>확인 필요</span>{:else if !outfit.conditions.length}<span class="muted">없음</span>{:else}{#each outfit.conditions as condition}<span>{shortCondition(condition)}</span>{/each}{/if}</span>
<span class="cell">{outfit.complete ? [...new Set(outfit.effects.map(effect => effect.target))].join(' · ') : '—'}</span>
<span class="cell stack">{#each [...new Set(outfit.effects.map(effect => effect.kind))] as kind}{#if statIcons[kind]}<span class="stat-kind"><span class={'stat-icon ' + statIcons[kind]} aria-hidden="true"></span><span>{kind}</span></span>{:else}<span class="effect-kind">{kind === '전체 파라미터' ? '전체' : kind === '스코어 서포트' ? '서포트' : kind}</span>{/if}{/each}</span>
<span class="cell numeric">{valueLabel(outfit, 'parameter')}</span><span class="cell numeric">{valueLabel(outfit, 'support')}</span>
<span class="chevron" aria-hidden="true"></span>
</div>
{#if expanded}
<div class="outfit-detail" id={'outfit-detail-' + outfit.card.id} aria-label={outfit.card.name + ' ' + outfit.name + ' 상세 정보'}>
<p class="full-description">{outfit.description}</p>
{#if outfit.complete}<div class="effect-details">{#each outfit.effects as effect}<dl><div><dt>조건</dt><dd>{effect.condition || '없음'}</dd></div><div><dt>대상</dt><dd>{effect.target}</dd></div><div><dt>효과</dt><dd>{effect.kind} <strong>{effect.value}%</strong></dd></div></dl>{/each}</div>{/if}
</div>
{/if}
</article>
{/each}
</section>
</main>
<style>
:global(*) { box-sizing: border-box; }
.table-panel { --columns: minmax(114px, 1.4fr) minmax(60px, .85fr) minmax(120px, 1.5fr) minmax(58px, .7fr) minmax(120px, 1.5fr) minmax(40px, .5fr) minmax(72px, .95fr) minmax(76px, .9fr) minmax(88px, 1fr) 12px; }
.list-header, .outfit-summary { display: grid; grid-template-columns: var(--columns); column-gap: 6px; align-items: center; padding: 10px 12px; min-height: 56px; text-align: center; }
.list-header, .outfit-entry { min-width: 940px; }
.list-header { color: #8993a5; background: #131923; font-size: 12px; font-weight: 750; }
.list-header > span:first-child, .list-header > span:nth-child(3) { text-align: left; }
.list-header button { display: flex; justify-content: center; align-items: center; gap: 3px; min-width: 0; padding: 5px 0; border: 0; border-radius: 7px; color: inherit; background: transparent; font: inherit; cursor: pointer; }
.list-header button:hover, .list-header button.active { color: #f08db7; background: #2a1b26; }
.outfit-entry { border-top: 1px solid #282f3d; scroll-margin-top: 80px; }
.outfit-summary { position: relative; font-size: 14px; }
.expand-button { position: absolute; inset: 0; z-index: 1; width: 100%; border: 0; background: transparent; cursor: pointer; }
.outfit-summary:hover, .outfit-summary:has(.expand-button:focus-visible) { background: #1d2431; }
.outfit-summary:has(.expand-button:focus-visible) { outline: 2px solid #8ba7d0; outline-offset: -2px; }
.cell, .chevron { position: relative; z-index: 2; min-width: 0; pointer-events: none; }
.name, .outfit-name { text-align: left; overflow-wrap: anywhere; font-weight: 750; line-height: 1.4; }
.generation { color: #aab3c3; word-break: keep-all; line-height: 1.4; font-size: 12px; }
.stack { display: grid; justify-items: center; gap: 3px; }
.condition { word-break: keep-all; line-height: 1.5; font-size: 12px; }
.variant { color: #9fb2ca; font-size: 11px; }
.numeric { color: #dbe1ec; font-weight: 800; font-variant-numeric: tabular-nums; }
.muted { color: #7d879a; }
.effect-kind { padding: 2px 5px; border-radius: 5px; color: #c5d0e0; background: #263044; font-size: 11px; }
.stat-kind { display: inline-flex; align-items: center; gap: 4px; color: #c5d0e0; font-size: 12px; font-weight: 700; white-space: nowrap; }
.stat-kind .stat-icon { flex-shrink: 0; }
.stat-icon { display: block; width: 20px; height: 20px; background: currentcolor; mask: center / contain no-repeat; }
.performance { color: #d95368; mask-image: url('/icons/stats/performance.webp'); }
.technique { color: #416fd1; mask-image: url('/icons/stats/technique.webp'); }
.sense { color: #8057c7; mask-image: url('/icons/stats/sense.webp'); }
.expanded { background: #191f2a; }
.outfit-detail { position: sticky; left: 0; width: 100cqw; padding: 12px; border-top: 1px solid #303746; background: #161e2b; }
.full-description { margin: 0; color: #d0d8e6; font-size: 13px; line-height: 1.7; }
.effect-details { display: grid; gap: 7px; margin-top: 10px; }
dl { display: grid; grid-template-columns: minmax(0, 1.5fr) 70px minmax(0, 1fr); gap: 12px; margin: 0; padding-top: 7px; border-top: 1px solid #303746; }
dl div { display: flex; gap: 7px; font-size: 12px; line-height: 1.6; }
dt { color: #8997ac; flex-shrink: 0; }
dd { margin: 0; color: #c8d2e2; overflow-wrap: anywhere; }
dd strong { color: #f1a1c3; }
.empty { margin: 0; padding: 50px 16px; color: #969fb0; text-align: center; }
button:focus-visible, input:focus-visible { outline: 2px solid #ed8bb5; outline-offset: 3px; }
@media (max-width: 680px) {
.outfit-detail { padding: 10px; }
dl { grid-template-columns: 1fr; gap: 3px; }
}
</style>