feat: release version 1.0.4

This commit is contained in:
2026-08-19 18:56:00 +09:00
parent bd9d5a6ba6
commit f910db8901
11 changed files with 506 additions and 105 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "holodream-tools",
"version": "1.0.3",
"version": "1.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "holodream-tools",
"version": "1.0.3",
"version": "1.0.4",
"engines": {
"node": ">=22.12.0"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "holodream-tools",
"version": "1.0.3",
"version": "1.0.4",
"private": true,
"type": "module",
"engines": {
+23
View File
@@ -0,0 +1,23 @@
# 홀로도리 계산기 1.0.4
카드 목록에서 액티브 정보를 더 쉽게 비교하고, 배치 분석과 조합 탐색의 입력 화면을 일관되게 개선한 업데이트입니다.
## 주요 변경 사항
- 카드 목록의 각 행에 액티브 주기, 확률, 지속 시간과 효과를 표시합니다.
- 액티브 주기, 확률, 지속 시간과 효과를 기준으로 오름차순·내림차순 정렬할 수 있습니다.
- 카드 목록 전체를 명함 또는 5개화 상태로 전환해 표시와 정렬 결과를 비교할 수 있습니다.
- 카드 목록 출력 조건에 `모두 취소` 기능을 추가했습니다.
- 액티브 또는 일부 값이 없는 카드는 `—`로 표시하고 정렬 시 항상 뒤에 배치합니다.
- 배치 분석의 카드 선택, 개화와 빈도 감소 입력을 열 헤더 중심의 표 형태로 정리했습니다.
- 조합 탐색의 탐색 조건 UI를 배치 분석과 같은 입력 구조와 스타일로 통일했습니다.
- 조합 탐색의 기수 보너스 입력을 숫자 `0~900` 범위로 제한했습니다.
- 보드 데이터의 활성 노드 수에서 항상 활성화된 가운데 연결 노드를 제외했습니다.
## 참고 사항
카드 목록의 정렬 기준, 정렬 방향과 명함·5개화 선택은 사용자 데이터에 저장됩니다. 이전 버전의 사용자 데이터와 백업 파일은 기본값을 자동으로 적용해 그대로 사용할 수 있습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.4.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activeEffectPercent, sortCardsByActive, type CardSummary, type SkillLevel } from '../models/card-list.ts';
function skill(level: number, values: Partial<SkillLevel> = {}): SkillLevel {
return {
level,
description: '',
coolTimeSeconds: null,
probabilityPercent: null,
durationSeconds: null,
...values
};
}
function card(id: string, active?: SkillLevel[]): CardSummary {
return {
id,
rarity: 5,
type: '큐트',
name: id,
versionName: '',
generation: '',
highestStat: '퍼포먼스',
details: { title: '', passive: [], active, special: [], costume: null }
};
}
test('sorts numeric active values in both directions and keeps missing values last', () => {
const cards = [
card('missing'),
card('slow', [skill(1, { coolTimeSeconds: 24 })]),
card('fast', [skill(1, { coolTimeSeconds: 18 })])
];
assert.deepEqual(sortCardsByActive(cards, 'coolTime', 'asc', 1).map(({ id }) => id), ['fast', 'slow', 'missing']);
assert.deepEqual(sortCardsByActive(cards, 'coolTime', 'desc', 1).map(({ id }) => id), ['slow', 'fast', 'missing']);
});
test('uses the selected skill level and preserves source order for ties', () => {
const cards = [
card('first', [skill(1, { effect: '스코어가 80% UP' }), skill(2, { effect: '스코어가 120% UP' })]),
card('second', [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(sortCardsByActive(cards, 'effect', 'desc', 2).map(({ id }) => id), ['first', 'second', 'same']);
});
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, { effect: '라이프가 회복됩니다.' })), null);
});
+82
View File
@@ -0,0 +1,82 @@
import type { CardListSortKey, SortDirection } from './user-data.ts';
export type CardType = '큐트' | '퓨어' | '해피';
export type HighestStat = '퍼포먼스' | '테크닉' | '센스';
export type SkillLevel = {
level: number;
description: string;
coolTimeSeconds: number | null;
probabilityPercent: number | null;
durationSeconds: number | null;
effect?: string;
condition?: string;
};
export type CardDetails = {
title: string;
passive: SkillLevel[];
active?: SkillLevel[];
special: SkillLevel[];
costume: { name: string; description: string } | null;
};
export type CardSummary = {
id: string;
rarity: number;
type: CardType;
name: string;
versionName: string;
generation: string;
highestStat: HighestStat;
details: CardDetails;
};
export function skillAtLevel(
levels: readonly SkillLevel[] | undefined,
selectedLevel: number
): SkillLevel | undefined {
return levels?.find((level) => level.level === selectedLevel) ?? levels?.at(-1);
}
export function activeEffectPercent(skill: SkillLevel | undefined): number | null {
const match = (skill?.effect || skill?.description)?.match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : null;
}
export function activeSortValue(
card: CardSummary,
sortKey: CardListSortKey,
selectedLevel: number
): number | null {
const active = skillAtLevel(card.details.active, selectedLevel);
if (!active) return null;
const value = {
coolTime: active.coolTimeSeconds,
probability: active.probabilityPercent,
duration: active.durationSeconds,
effect: activeEffectPercent(active)
}[sortKey];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
export function sortCardsByActive(
cards: readonly CardSummary[],
sortKey: CardListSortKey | null,
sortDirection: SortDirection,
selectedLevel: number
): CardSummary[] {
if (!sortKey) return [...cards];
const direction = sortDirection === 'asc' ? 1 : -1;
return cards
.map((card, index) => ({ card, index, value: activeSortValue(card, sortKey, selectedLevel) }))
.sort((left, right) => {
if (left.value === null) return right.value === null ? left.index - right.index : 1;
if (right.value === null) return -1;
return (left.value - right.value) * direction || left.index - right.index;
})
.map(({ card }) => card);
}
+7
View File
@@ -12,6 +12,10 @@ export type SavedDeck = {
updatedAt: string;
};
export type CardListSortKey = 'coolTime' | 'probability' | 'duration' | 'effect';
export type SortDirection = 'asc' | 'desc';
export type CardSkillLevel = 1 | 2;
export type CardListPreferences = {
types: string[];
stats: string[];
@@ -20,6 +24,9 @@ export type CardListPreferences = {
activeProbabilities: string[];
cardVariants: string[];
generations: string[];
sortKey: CardListSortKey | null;
sortDirection: SortDirection;
skillLevel: CardSkillLevel;
};
export type BoardCardPlacements = Record<string, Record<string, string>>;
+19 -2
View File
@@ -1,4 +1,4 @@
import type { CardListPreferences, CollectionCardState, UserData } from '$lib/models/user-data';
import type { CardListPreferences, CardListSortKey, CardSkillLevel, CollectionCardState, SortDirection, UserData } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data';
export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1';
@@ -17,6 +17,20 @@ function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function cardListSortKey(value: unknown, fallback: CardListSortKey | null): CardListSortKey | null {
return value === 'coolTime' || value === 'probability' || value === 'duration' || value === 'effect'
? value
: fallback;
}
function sortDirection(value: unknown, fallback: SortDirection): SortDirection {
return value === 'asc' || value === 'desc' ? value : fallback;
}
function cardSkillLevel(value: unknown, fallback: CardSkillLevel): CardSkillLevel {
return value === 1 || value === 2 ? value : fallback;
}
function parseCollection(value: Record<string, unknown>): UserData['collection'] {
return Object.fromEntries(
Object.entries(value).flatMap(([cardId, state]) => {
@@ -79,7 +93,10 @@ export function parseUserData(text: string, fallbackPreferences: CardListPrefere
specialConditions: stringArray(preferences.specialConditions, fallbackPreferences.specialConditions),
activeProbabilities: stringArray(preferences.activeProbabilities, fallbackPreferences.activeProbabilities),
cardVariants: stringArray(preferences.cardVariants, fallbackPreferences.cardVariants),
generations: stringArray(preferences.generations, fallbackPreferences.generations)
generations: stringArray(preferences.generations, fallbackPreferences.generations),
sortKey: cardListSortKey(preferences.sortKey, fallbackPreferences.sortKey),
sortDirection: sortDirection(preferences.sortDirection, fallbackPreferences.sortDirection),
skillLevel: cardSkillLevel(preferences.skillLevel, fallbackPreferences.skillLevel)
}
};
}
+11 -5
View File
@@ -474,6 +474,11 @@
</div>
<div class="card-fields">
<div class="card-field-header" aria-hidden="true">
<span>카드 선택</span>
<span>개화</span>
<span>빈도 감소 고정</span>
</div>
{#each selectedCardIds as selectedId, slot}
<div class="card-field">
<label><span>{slot + 1}번 선택</span>
@@ -484,7 +489,6 @@
</select>
</label>
<div class="awakening">
<span>개화</span>
<div class="awakening-stepper">
<button
type="button"
@@ -502,7 +506,6 @@
</div>
</div>
<div class="cooldown-lock">
<span>빈도 감소 고정</span>
<div class="reduction-options">
{#each REDUCTION_LOCK_OPTIONS as reduction}
<button
@@ -664,16 +667,18 @@
.chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.card-fields { display: grid; gap: 9px; margin-top: 18px; }
.card-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px); gap: 9px; }
.card-field-header, .card-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px); gap: 9px; }
.card-field-header { padding: 0 2px; color: #8f98a8; font-size: 13px; font-weight: 800; }
.card-field-header span { padding-left: 2px; }
.card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
.card-field > .awakening { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
.card-field > .awakening { min-width: 0; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
.awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; }
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
.awakening-stepper button:disabled { color: #525b69; background: #171c24; cursor: default; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; white-space: nowrap; }
.cooldown-lock { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
.cooldown-lock { min-width: 0; }
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 13px; font-weight: 750; cursor: pointer; }
.reduction-options button:first-child { border-left: 0; }
@@ -755,6 +760,7 @@
.power-summary { grid-column: 1 / -1; }
.chart-meta { grid-column: 1 / -1; height: auto; }
.card-field { grid-template-columns: minmax(0, 1fr) 102px; }
.card-field-header { display: none; }
.card-field > label { grid-template-columns: 58px minmax(0, 1fr); }
.card-field > .awakening { grid-template-columns: 1fr; }
.awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; }
+249 -73
View File
@@ -1,43 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json';
import type { CardListPreferences, UserData } from '$lib/models/user-data';
import { skillAtLevel, sortCardsByActive, type CardSummary, type CardType, type HighestStat, type SkillLevel } from '$lib/models/card-list';
import type { CardListPreferences, CardListSortKey, CardSkillLevel, SortDirection, UserData } from '$lib/models/user-data';
import { loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
type CardType = '큐트' | '퓨어' | '해피';
type HighestStat = '퍼포먼스' | '테크닉' | '센스';
type SkillConditionStatus = '일반' | '조건부';
type ActiveProbability = '높음' | '중간';
type SkillLevel = {
level: number;
description: string;
coolTimeSeconds: number | null;
probabilityPercent: number | null;
durationSeconds: number | null;
effect?: string;
condition?: string;
};
type CardDetails = {
title: string;
passive: SkillLevel[];
active: SkillLevel[];
special: SkillLevel[];
costume: { name: string; description: string } | null;
};
type CardSummary = {
id: string;
rarity: number;
type: CardType;
name: string;
versionName: string;
generation: string;
highestStat: HighestStat;
details: CardDetails;
};
const allCards = cardSummary.cards as CardSummary[];
const cards = allCards.filter((card) => card.rarity === 5);
const typeOptions: CardType[] = ['큐트', '퓨어', '해피'];
@@ -59,8 +29,10 @@
let selectedActiveProbabilities = [...activeProbabilityOptions];
let selectedCardVariants = [...cardVariantOptions];
let selectedGenerations = [...generationOptions];
let sortKey: CardListSortKey | null = null;
let sortDirection: SortDirection = 'asc';
let expandedCardId: string | null = null;
let selectedSkillLevel = 1;
let selectedSkillLevel: CardSkillLevel = 1;
let collection: UserData['collection'] = {};
let memberBoards: UserData['memberBoards'] = {};
let boardCards: UserData['boardCards'] = {};
@@ -95,14 +67,19 @@
} catch {}
});
$: filteredCards = cards.filter((card) =>
selectedTypes.includes(card.type)
&& selectedStats.includes(card.highestStat)
&& selectedActiveConditions.includes(skillConditionStatus(card.details.active))
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
&& selectedActiveProbabilities.includes(activeProbabilityStatus(card.details.active))
&& selectedCardVariants.includes(cardVariantStatus(card))
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
$: filteredCards = sortCardsByActive(
cards.filter((card) =>
selectedTypes.includes(card.type)
&& selectedStats.includes(card.highestStat)
&& matchesSkillCondition(selectedActiveConditions, card.details.active ?? [])
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
&& matchesActiveProbability(card.details.active ?? [])
&& selectedCardVariants.includes(cardVariantStatus(card))
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
),
sortKey,
sortDirection,
selectedSkillLevel
);
$: allSelected = selectedTypes.length === typeOptions.length
@@ -113,6 +90,14 @@
&& selectedCardVariants.length === cardVariantOptions.length
&& selectedGenerations.length === generationOptions.length;
$: allDeselected = selectedTypes.length === 0
&& selectedStats.length === 0
&& selectedActiveConditions.length === 0
&& selectedSpecialConditions.length === 0
&& selectedActiveProbabilities.length === 0
&& selectedCardVariants.length === 0
&& selectedGenerations.length === 0;
$: if (expandedCardId && !filteredCards.some((card) => card.id === expandedCardId)) {
expandedCardId = null;
}
@@ -127,6 +112,16 @@
selectedGenerations = [...generationOptions];
}
function selectNone() {
selectedTypes = [];
selectedStats = [];
selectedActiveConditions = [];
selectedSpecialConditions = [];
selectedActiveProbabilities = [];
selectedCardVariants = [];
selectedGenerations = [];
}
function defaultPreferences(): CardListPreferences {
return {
types: [...typeOptions],
@@ -135,7 +130,10 @@
specialConditions: [...skillConditionOptions],
activeProbabilities: [...activeProbabilityOptions],
cardVariants: [...cardVariantOptions],
generations: [...generationOptions]
generations: [...generationOptions],
sortKey: null,
sortDirection: 'asc',
skillLevel: 1
};
}
@@ -147,7 +145,10 @@
specialConditions: [...selectedSpecialConditions],
activeProbabilities: [...selectedActiveProbabilities],
cardVariants: [...selectedCardVariants],
generations: [...selectedGenerations]
generations: [...selectedGenerations],
sortKey,
sortDirection,
skillLevel: selectedSkillLevel
};
}
@@ -160,6 +161,9 @@
selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value));
selectedGenerations = [...new Set(data.preferences.generations.flatMap(generationMemberships))]
.filter((value) => generationOptions.includes(value));
sortKey = data.preferences.sortKey;
sortDirection = data.preferences.sortDirection;
selectedSkillLevel = data.preferences.skillLevel;
collection = data.collection;
memberBoards = data.memberBoards;
boardCards = data.boardCards;
@@ -189,23 +193,55 @@
return levels.some((level) => Boolean(level.condition)) ? '조건부' : '일반';
}
function activeProbabilityStatus(levels: SkillLevel[]): ActiveProbability {
function matchesSkillCondition(selected: SkillConditionStatus[], levels: SkillLevel[]): boolean {
return levels.length === 0
? selected.length === skillConditionOptions.length
: selected.includes(skillConditionStatus(levels));
}
function activeProbabilityStatus(levels: SkillLevel[]): ActiveProbability | null {
if (levels.length === 0) return null;
return levels.some((level) => level.probabilityPercent === 55) ? '높음' : '중간';
}
function matchesActiveProbability(levels: SkillLevel[]): boolean {
const status = activeProbabilityStatus(levels);
return status === null
? selectedActiveProbabilities.length === activeProbabilityOptions.length
: selectedActiveProbabilities.includes(status);
}
function toggleCard(cardId: string) {
expandedCardId = expandedCardId === cardId ? null : cardId;
selectedSkillLevel = 1;
}
function skillAtLevel(levels: SkillLevel[], selectedLevel: number) {
return levels.find((level) => level.level === selectedLevel) ?? levels.at(-1);
function toggleSort(nextSortKey: CardListSortKey) {
if (sortKey === nextSortKey) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = nextSortKey;
sortDirection = 'asc';
}
function probabilityLabel(probabilityPercent: number | null) {
function sortButtonLabel(key: CardListSortKey, label: string) {
if (sortKey !== key) return `${label} 오름차순 정렬`;
return `${label} ${sortDirection === 'asc' ? '내림차순' : '오름차순'}으로 변경`;
}
function secondsLabel(seconds: number | null | undefined) {
return seconds == null ? '—' : `${seconds}초`;
}
function probabilityLabel(probabilityPercent: number | null | undefined) {
if (probabilityPercent === 55) return '높음';
if (probabilityPercent === 46) return '중간';
return probabilityPercent === null ? '—' : `${probabilityPercent}%`;
return probabilityPercent == null ? '—' : `${probabilityPercent}%`;
}
function activeEffectLabel(active: SkillLevel | undefined) {
return active?.effect || active?.description || '—';
}
function descriptionSegments(description: string, highlightNumbers = false) {
@@ -287,7 +323,10 @@
<section class="filters" aria-labelledby="filter-heading">
<div class="filter-header">
<h2 id="filter-heading">출력 조건</h2>
<button class="select-all" type="button" onclick={selectAll} disabled={allSelected}>모두 선택</button>
<div class="filter-actions">
<button class="filter-action" type="button" onclick={selectNone} disabled={allDeselected}>모두 취소</button>
<button class="filter-action" type="button" onclick={selectAll} disabled={allSelected}>모두 선택</button>
</div>
</div>
<div class="filter-groups">
@@ -392,12 +431,30 @@
</section>
<section class="table-panel" aria-label="카드 목록">
<div class="list-header" aria-hidden="true">
<div class="list-header">
<span>타입</span>
<span>주스탯</span>
<span>기수</span>
<span>이름</span>
<span></span>
<button class="sort-button" type="button" class:active={sortKey === 'coolTime'} aria-label={sortButtonLabel('coolTime', '주기')} onclick={() => toggleSort('coolTime')}>
<span>주기</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'coolTime' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'probability'} aria-label={sortButtonLabel('probability', '확률')} onclick={() => toggleSort('probability')}>
<span>확률</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'probability' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'duration'} aria-label={sortButtonLabel('duration', '지속')} onclick={() => toggleSort('duration')}>
<span>지속</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'duration' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'effect'} aria-label={sortButtonLabel('effect', '효과')} onclick={() => toggleSort('effect')}>
<span>효과</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'effect' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<div class="header-level-control">
<span>개화</span>
<div class="level-toggle header-level-toggle" aria-label="목록에 표시할 스킬 개화 상태">
<button type="button" class:active={selectedSkillLevel === 1} aria-pressed={selectedSkillLevel === 1} onclick={() => selectedSkillLevel = 1}>명함</button>
<button type="button" class:active={selectedSkillLevel === 2} aria-pressed={selectedSkillLevel === 2} onclick={() => selectedSkillLevel = 2}>5 개화</button>
</div>
</div>
<span></span>
</div>
@@ -448,13 +505,24 @@
{#if card.versionName}<span class="version-badge">{card.versionName}</span>{/if}
</span>
</span>
<span class="summary-cell active-cycle-cell active-summary-cell">
<span class="mobile-label">주기</span>
<span class="active-value">{secondsLabel(active?.coolTimeSeconds)}</span>
</span>
<span class="summary-cell active-probability-cell active-summary-cell">
<span class="mobile-label">확률</span>
<span class="active-value">{probabilityLabel(active?.probabilityPercent)}</span>
</span>
<span class="summary-cell active-duration-cell active-summary-cell">
<span class="mobile-label">지속</span>
<span class="active-value">{secondsLabel(active?.durationSeconds)}</span>
</span>
<span class="summary-cell active-effect-cell active-summary-cell">
<span class="mobile-label">효과</span>
<span class="active-effect" title={activeEffectLabel(active)}>{activeEffectLabel(active)}</span>
</span>
<span class="summary-cell level-cell">
{#if expanded}
<div class="level-toggle inline-level-toggle" aria-label="스킬 개화 상태">
<button type="button" class:active={selectedSkillLevel === 1} aria-pressed={selectedSkillLevel === 1} onclick={() => selectedSkillLevel = 1}>명함</button>
<button type="button" class:active={selectedSkillLevel === 2} aria-pressed={selectedSkillLevel === 2} onclick={() => selectedSkillLevel = 2}>5 개화</button>
</div>
{/if}
<span class="skill-level-label">{selectedSkillLevel === 1 ? '명함' : '5 개화'}</span>
</span>
<span class="chevron" aria-hidden="true"></span>
</div>
@@ -704,7 +772,12 @@
letter-spacing: -0.025em;
}
.select-all {
.filter-actions {
display: flex;
gap: 8px;
}
.filter-action {
padding: 7px 12px;
border: 1px solid #dce1ea;
border-radius: 9px;
@@ -717,13 +790,13 @@
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
}
.select-all:hover:not(:disabled) {
.filter-action:hover:not(:disabled) {
border-color: #ef8bb5;
color: #d83f82;
background: #fff8fb;
}
.select-all:disabled {
.filter-action:disabled {
opacity: 0.42;
cursor: default;
}
@@ -831,7 +904,8 @@
}
.table-panel {
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
border: 1px solid #e3e7ef;
border-radius: 22px;
background: rgb(255 255 255 / 90%);
@@ -842,11 +916,12 @@
.list-header,
.card-summary {
display: grid;
grid-template-columns: clamp(72px, 5vw, 88px) clamp(84px, 6vw, 104px) clamp(150px, 11vw, 184px) minmax(280px, 1fr) clamp(116px, 9vw, 144px) 40px;
grid-template-columns: 62px 72px 110px minmax(160px, 1fr) 66px 66px 66px minmax(160px, 1.15fr) 190px 32px;
align-items: center;
}
.list-header {
min-width: 1260px;
min-height: 54px;
padding: 0 24px;
border-bottom: 1px solid #e8ebf1;
@@ -856,7 +931,44 @@
font-weight: 700;
}
.list-header .sort-button {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 5px;
width: fit-content;
padding: 6px 7px;
border: 0;
border-radius: 7px;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
transition: color 150ms ease, background 150ms ease;
}
.list-header .sort-button:hover,
.list-header .sort-button.active {
color: #d83f82;
background: #fff0f7;
}
.list-header .sort-button:focus-visible {
outline: 3px solid rgb(73 134 231 / 25%);
outline-offset: 1px;
}
.sort-indicator {
color: #a4adbb;
font-size: 12px;
}
.list-header .sort-button.active .sort-indicator {
color: currentcolor;
}
.card-entry {
min-width: 1260px;
border-bottom: 1px solid #edf0f5;
}
@@ -915,11 +1027,49 @@
gap: 10px;
}
.active-summary-cell {
padding-right: 8px;
}
.active-value,
.active-effect {
color: #4d586d;
font-size: 13px;
font-weight: 750;
font-variant-numeric: tabular-nums;
}
.active-effect {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.level-cell {
display: flex;
justify-content: flex-end;
}
.header-level-control {
display: flex;
align-items: center;
gap: 9px;
}
.header-level-toggle {
flex: 0 0 auto;
}
.skill-level-label {
padding: 4px 9px;
border-radius: 999px;
color: #b43d72;
background: #fff0f7;
font-size: 12px;
font-weight: 800;
}
.mobile-label {
display: none;
}
@@ -958,12 +1108,6 @@
background: #fff;
}
.inline-level-toggle {
position: relative;
z-index: 3;
pointer-events: auto;
}
.level-toggle button {
min-width: 50px;
padding: 3px 7px;
@@ -1395,13 +1539,13 @@
box-shadow: 0 14px 42px rgb(0 0 0 / 24%);
}
.select-all {
.filter-action {
border-color: #343c4b;
color: #b5bdcb;
background: #1d2430;
}
.select-all:hover:not(:disabled) {
.filter-action:hover:not(:disabled) {
border-color: #c85b8b;
color: #f08db7;
background: #2a1c27;
@@ -1442,6 +1586,22 @@
background: #131923;
}
.list-header .sort-button:hover,
.list-header .sort-button.active {
color: #f08db7;
background: #2a1b26;
}
.active-value,
.active-effect {
color: #c2cad8;
}
.skill-level-label {
color: #f08db7;
background: #2a1b26;
}
.card-entry {
border-color: #282f3d;
}
@@ -1574,7 +1734,7 @@
@media (max-width: 1100px) {
.list-header,
.card-summary {
grid-template-columns: 72px 84px 150px minmax(0, 1fr) 116px 36px;
grid-template-columns: 62px 72px 110px minmax(160px, 1fr) 66px 66px 66px minmax(160px, 1.15fr) 190px 32px;
}
}
@@ -1625,12 +1785,14 @@
}
.card-entry {
min-width: 0;
padding: 0;
}
.card-summary {
display: grid;
grid-template-columns: 72px 84px minmax(0, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
gap: 14px 12px;
padding: 19px 0;
padding-right: 32px;
@@ -1661,6 +1823,20 @@
grid-column: 1 / -1;
}
.active-summary-cell {
padding-right: 0;
}
.active-effect-cell {
grid-column: 1 / -1;
}
.active-effect {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.level-cell {
display: flex;
justify-content: flex-end;
+49 -19
View File
@@ -277,6 +277,27 @@
return Math.max(0, Math.min(900, Number.isFinite(value) ? Math.round(Number(value)) : 0));
}
function sanitizeGenerationBonusInput(input: HTMLInputElement, previousValue: number | null): string {
if (input.value === '') {
input.value = '';
return '';
}
if (!/^\d+$/.test(input.value)) {
input.value = previousValue === null ? '' : String(previousValue);
return input.value;
}
const value = Number(input.value);
if (value > 900) {
input.value = previousValue === null ? '' : String(previousValue);
return input.value;
}
input.value = String(value);
return input.value;
}
function saveState(state: SavedState): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
@@ -913,6 +934,7 @@
<div class="song-fields">
<label>악곡<select bind:value={selectedSongId} on:change={() => result = null}>{#each songs as song}<option value={song.id}>{song.title}</option>{/each}</select></label>
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span></div>
</div>
<div class="candidate-fields">
<div class="candidate-awakening">
@@ -923,20 +945,20 @@
<button type="button" disabled={candidateAwakening >= 5} aria-label="자동 후보 개화 높이기" on:click={() => updateCandidateAwakening(candidateAwakening + 1)}>›</button>
</div>
</div>
<label>자동 후보 기수 보너스 덮어쓰기<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:change={(event) => updateCandidateGenerationBonus(event.currentTarget.value)}></label>
<label>자동 후보 기수 보너스 덮어쓰기 (0~900)<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:input={(event) => updateCandidateGenerationBonus(sanitizeGenerationBonusInput(event.currentTarget, candidateGenerationBonus))}></label>
</div>
<p class="candidate-guide">
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
</p>
<div class="fixed-fields">
<div class="fixed-column-headings">
<span>고정 멤버</span><span>개화</span><span>빈도 감소 고정</span>
<div><span>파란 노드 전체</span><span class="generation-heading">기수 보너스<br>덮어쓰기</span></div>
<div class="fixed-column-headings" aria-hidden="true">
<span>고정 카드</span><span>개화</span><span>빈도 감소 고정</span>
<div><span>파란 노드 전체</span><span class="generation-heading">기수 보너스<br>덮어쓰기 (0~900)</span></div>
</div>
{#each fixedCardIds as selectedId, slot}
<div class="fixed-field">
<label>{slot + 1}번 고정
<label><span>{slot + 1}번 고정</span>
<select value={selectedId} on:change={(event) => updateFixedCard(slot, event.currentTarget.value)}>
<option value="">고정하지 않음</option>
{#each fiveStarCards as card}<option value={card.id} disabled={selectedInAnotherSlot(card, slot)}>{cardLabel(card)}</option>{/each}
@@ -961,7 +983,7 @@
<input type="checkbox" disabled={!selectedId} checked={fixedAllBlueNodes[slot]} aria-label={`${slot + 1}번 고정 카드 파란 노드 전체 활성화`} on:change={() => toggleFixedAllBlueNodes(slot)}>
<span aria-hidden="true"></span>
</label>
<input class="generation-input" type="text" inputmode="numeric" pattern="[0-9]*" disabled={!selectedId} value={fixedGenerationBonusOverrides[slot] ?? ''} placeholder="미사용" aria-label={`${slot + 1}번 고정 카드 기수 보너스 덮어쓰기`} on:change={(event) => updateFixedGenerationBonus(slot, event.currentTarget.value)}>
<input class="generation-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" disabled={!selectedId} value={fixedGenerationBonusOverrides[slot] ?? ''} placeholder="미사용" aria-label={`${slot + 1}번 고정 카드 기수 보너스 덮어쓰기 (0~900)`} on:input={(event) => updateFixedGenerationBonus(slot, sanitizeGenerationBonusInput(event.currentTarget, fixedGenerationBonusOverrides[slot] ?? null))}>
</div>
</div>
{/each}
@@ -1056,26 +1078,32 @@
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select, .number-input { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus, .number-input:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 1fr) clamp(130px, 11vw, 180px); gap: 10px; margin-top: 22px; }
.candidate-fields { display: grid; grid-template-columns: clamp(130px, 11vw, 180px) clamp(260px, 24vw, 380px); gap: 10px; margin-top: 14px; padding-top: 14px; border-top: 1px solid #292f39; }
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) minmax(220px, .8fr); align-items: end; gap: 12px; margin-top: 22px; }
.chart-meta { display: flex; align-items: center; gap: 8px; height: 42px; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.candidate-fields { display: grid; grid-template-columns: clamp(130px, 12vw, 180px) clamp(260px, 24vw, 380px); gap: 9px; margin-top: 18px; }
.candidate-awakening { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
.candidate-guide { margin: 7px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.fixed-fields { display: grid; gap: 9px; margin-top: 16px; }
.fixed-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.fixed-column-headings { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(120px, 11vw, 170px) clamp(304px, 26vw, 390px) clamp(180px, 16vw, 240px); gap: 9px; margin-bottom: -3px; }
.fixed-column-headings > span { align-self: center; color: #8f98a8; font-size: 12px; font-weight: 800; text-align: center; }
.fixed-column-headings > span:first-child { text-align: left; }
.fixed-column-headings { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px) clamp(180px, 16vw, 240px); gap: 9px; padding: 0 2px; }
.fixed-column-headings > span { align-self: center; padding-left: 2px; color: #8f98a8; font-size: 13px; font-weight: 800; }
.fixed-column-headings > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 12px; font-weight: 800; text-align: center; }
.generation-heading { line-height: 1.25; white-space: nowrap; }
.fixed-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(120px, 11vw, 170px) clamp(304px, 26vw, 390px) clamp(180px, 16vw, 240px); gap: 9px; }
.fixed-awakening, .fixed-reduction { display: grid; align-self: end; align-items: center; height: 42px; }
.awakening-stepper { display: grid; grid-template-columns: 30px minmax(0, 1fr) 30px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 22px; cursor: pointer; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; }
.fixed-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px) clamp(180px, 16vw, 240px); gap: 9px; }
.fixed-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
.fixed-awakening, .fixed-reduction { min-width: 0; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
.awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; }
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; white-space: nowrap; }
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.reduction-options button { min-width: 0; padding: 0 5px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 12px; font-weight: 750; cursor: pointer; }
.reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 13px; font-weight: 750; cursor: pointer; }
.reduction-options button:first-child { border-left: 0; }
.reduction-options button:hover:not(:disabled) { color: #e9edf3; background: #202630; }
.reduction-options button.active { color: #171019; background: #ed8bb5; }
.reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; }
.fixed-overrides { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; align-self: end; height: 42px; }
.override-checkbox { display: grid; place-items: center; cursor: pointer; }
.override-checkbox input { position: absolute; width: 1px; height: 1px; opacity: 0; }
@@ -1088,13 +1116,14 @@
.generation-input::placeholder { color: #68717f; opacity: 1; }
.generation-input:disabled { opacity: .35; }
.awakening-stepper button:disabled, .reduction-options button:disabled { opacity: .35; cursor: default; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 26px; padding-top: 20px; border-top: 1px solid #303641; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; }
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
.outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; }
.outfit-effect p { margin: 0; color: #b9c1cd; font-size: 14px; line-height: 1.55; }
.hint { margin: 13px 0 0; color: #858f9f; font-size: 13px; line-height: 1.6; }
.error { margin: 14px 0 0; color: #ff9f9f; font-size: 13px; }
.search { width: 100%; height: 46px; margin-top: 18px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.search { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.search:hover:not(:disabled) { background: #f2a0c2; }
.search:disabled { opacity: .55; cursor: default; }
.compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
.compute-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
@@ -1150,6 +1179,7 @@
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; }
.chart-meta { grid-column: 1 / -1; height: auto; }
.candidate-fields { grid-template-columns: 1fr; }
.fixed-column-headings { display: none; }
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
+9 -3
View File
@@ -92,7 +92,10 @@
specialConditions: ['일반', '조건부'],
activeProbabilities: ['높음', '중간'],
cardVariants: ['일반', ...new Set(fiveStarCards.map((card) => card.versionName).filter(Boolean))],
generations: [...new Set(fiveStarCards.map((card) => card.generation).filter(Boolean))]
generations: [...new Set(fiveStarCards.map((card) => card.generation).filter(Boolean))],
sortKey: null,
sortDirection: 'asc',
skillLevel: 1
};
}
@@ -131,7 +134,10 @@
$: filteredFourStarCards = filteredCards.filter((card) => card.rarity === 4);
$: selectedBoardMember = boardMembers.find((member) => member.id === selectedMemberId) ?? null;
$: selectedActiveBoardNodes = userData.memberBoards[selectedMemberId] ?? [];
$: selectedActiveBoardNodeCount = new Set([centerNodeId(), ...selectedActiveBoardNodes].filter(Boolean)).size;
$: selectedActiveBoardNodeCount = new Set(selectedActiveBoardNodes.filter((nodeId) => nodeId !== centerNodeId())).size;
$: selectedBoardNodeCount = selectedBoardMember
? boardModels[selectedBoardMember.positionGroupId].filter((node) => node.groupId !== centerNodeId()).length
: 0;
$: selectedBoardEdges = selectedBoardMember ? boardEdges(boardModels[selectedBoardMember.positionGroupId]) : [];
$: selectedBoardCardPlacements = userData.boardCards[selectedMemberId] ?? {};
$: selectedConnectionBoosts = connectionBoostsForMember(
@@ -1291,7 +1297,7 @@
{#if selectedBoardMember}
<div class="board-toolbar">
<p><strong>{selectedActiveBoardNodeCount}</strong> / {boardModels[selectedBoardMember.positionGroupId].length}개 활성화</p>
<p><strong>{selectedActiveBoardNodeCount}</strong> / {selectedBoardNodeCount}개 활성화</p>
<div><button type="button" on:click={activateAllBlueNodes}>모든 파란 노드 활성화</button><button type="button" on:click={() => setAllBoardNodes(true)}>모두 활성화</button><button type="button" on:click={() => setAllBoardNodes(false)}>모두 해제</button></div>
</div>
<div