feat: improve combination search and offline build
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import boardSummary from '$lib/data/boards-summary.json';
|
||||
import memorySummary from '$lib/data/memory-summary.json';
|
||||
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
||||
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
||||
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
|
||||
import {
|
||||
calculateBoardEffects,
|
||||
@@ -12,10 +13,12 @@
|
||||
generateCombinationCandidates,
|
||||
memoryParameterPermil,
|
||||
perfectNoteScoreFromPower,
|
||||
resolveCandidateAwakening,
|
||||
toOptimizerCard
|
||||
} from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard } from '$lib/engine/index.ts';
|
||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||
|
||||
type CardRow = (typeof cardSummary.cards)[number];
|
||||
type DifficultyKey = 'hard' | 'expert';
|
||||
@@ -31,6 +34,7 @@
|
||||
skillPotential: number;
|
||||
};
|
||||
type RankedResult = { result: ExactOptimizationResult; overallPower: number };
|
||||
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
|
||||
type SavedState = {
|
||||
schemaVersion: 1;
|
||||
songId: string;
|
||||
@@ -60,8 +64,9 @@
|
||||
|
||||
const STORAGE_KEY = 'holodori.combination.v1';
|
||||
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||
const SCREENING_LIMIT = 3_000;
|
||||
const EXACT_FINALIST_LIMIT = 24;
|
||||
const SCREENING_LIMIT = 4_000;
|
||||
const BASE_EXACT_FINALIST_LIMIT = 48;
|
||||
const ALTERNATIVE_EXACT_FINALIST_LIMIT = 48;
|
||||
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
|
||||
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
|
||||
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
|
||||
@@ -99,6 +104,7 @@
|
||||
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||
let result: ExactOptimizationResult | null = null;
|
||||
let rankedResults: RankedResult[] = [];
|
||||
let displayedRankings: DisplayRankedResult[] = [];
|
||||
let baselineState: SavedAnalysisState | null = null;
|
||||
let baselineResult: ExactOptimizationResult | null = null;
|
||||
let baselineOverallPower = 0;
|
||||
@@ -118,6 +124,10 @@
|
||||
: undefined;
|
||||
$: baselineMatchesConditions = Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
|
||||
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
|
||||
$: displayedRankings = buildDisplayedRankings(
|
||||
rankedResults,
|
||||
comparableToBaseline ? { result: baselineResult!, overallPower: baselineOverallPower, isBaseline: true } : null
|
||||
);
|
||||
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
|
||||
$: if (!result && rankedResults.length > 0) rankedResults = [];
|
||||
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
||||
@@ -191,7 +201,7 @@
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
worker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker = createScoreOptimizerWorker();
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.type === 'progress') progress = event.data.progress;
|
||||
if (event.data.type === 'combination-result') {
|
||||
@@ -206,7 +216,7 @@
|
||||
searchPhase = 'idle';
|
||||
}
|
||||
};
|
||||
baselineWorker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
||||
baselineWorker = createScoreOptimizerWorker();
|
||||
baselineWorker.onmessage = (event) => {
|
||||
if (event.data.type === 'progress') baselineProgress = event.data.progress;
|
||||
if (event.data.type === 'result') {
|
||||
@@ -326,7 +336,13 @@
|
||||
|
||||
function awakeningForCard(card: CardRow): number {
|
||||
const slot = fixedSlot(card.id);
|
||||
return slot >= 0 ? fixedAwakenings[slot] : candidateAwakening;
|
||||
if (slot >= 0) return fixedAwakenings[slot];
|
||||
const storedCard = collection[card.id];
|
||||
return resolveCandidateAwakening(
|
||||
candidateAwakening,
|
||||
Boolean(storedCard?.owned),
|
||||
normalizeAwakening(storedCard?.awakening)
|
||||
);
|
||||
}
|
||||
|
||||
function updateLeader(characterId: string): void {
|
||||
@@ -358,6 +374,21 @@
|
||||
return { adapted, activeValue, specialValue };
|
||||
}
|
||||
|
||||
function roughCombinationScore(cards: readonly CardRow[]): number {
|
||||
const statTotal = cards.reduce((sum, card) => sum + roughStatScore(card), 0);
|
||||
const skillPotential = cards.reduce((sum, card) => {
|
||||
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards);
|
||||
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
|
||||
Math.max(1, adapted.active.cooldownSeconds) * ((adapted.active.probabilityPercent ?? 100) / 100);
|
||||
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
|
||||
const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100;
|
||||
const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) =>
|
||||
effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0);
|
||||
return sum + activeValue + specialValue + activationValue + passiveValue;
|
||||
}, 0);
|
||||
return statTotal * (1 + skillPotential / 500);
|
||||
}
|
||||
|
||||
function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string {
|
||||
return cards.map((card) => card.id).sort().join('|');
|
||||
}
|
||||
@@ -377,13 +408,23 @@
|
||||
if (fixedCards.length === 4) {
|
||||
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, SCREENING_LIMIT);
|
||||
}
|
||||
const perStrategy = Math.ceil(SCREENING_LIMIT / 3);
|
||||
const perStrategy = Math.ceil(SCREENING_LIMIT / 4);
|
||||
const combined = new Map<string, CardRow[]>();
|
||||
for (const score of [roughCardScore, roughSkillScore, roughStatScore]) {
|
||||
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
|
||||
combined.set(combinationKey(cards), cards);
|
||||
}
|
||||
}
|
||||
for (const cards of generateCombinationCandidates(
|
||||
fixedCards,
|
||||
fiveStarCards,
|
||||
5,
|
||||
roughStatScore,
|
||||
perStrategy,
|
||||
roughCombinationScore
|
||||
)) {
|
||||
combined.set(combinationKey(cards), cards);
|
||||
}
|
||||
return Array.from(combined.values()).slice(0, SCREENING_LIMIT);
|
||||
}
|
||||
|
||||
@@ -393,16 +434,56 @@
|
||||
const add = (candidates: PreparedCandidate[], count: number) => {
|
||||
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
|
||||
};
|
||||
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 12);
|
||||
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 6);
|
||||
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 6);
|
||||
if (selected.size < EXACT_FINALIST_LIMIT) {
|
||||
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 24);
|
||||
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 12);
|
||||
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 12);
|
||||
if (selected.size < BASE_EXACT_FINALIST_LIMIT) {
|
||||
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
|
||||
selected.set(combinationKey(candidate.cards), candidate);
|
||||
if (selected.size >= EXACT_FINALIST_LIMIT) break;
|
||||
if (selected.size >= BASE_EXACT_FINALIST_LIMIT) break;
|
||||
}
|
||||
}
|
||||
return Array.from(selected.values()).slice(0, EXACT_FINALIST_LIMIT);
|
||||
return Array.from(selected.values()).slice(0, BASE_EXACT_FINALIST_LIMIT);
|
||||
}
|
||||
|
||||
function addAlternativeVariantFinalists(baseFinalists: PreparedCandidate[]): PreparedCandidate[] {
|
||||
if (fixedCards.length === 4) return baseFinalists;
|
||||
const selected = new Map(baseFinalists.map((candidate) => [combinationKey(candidate.cards), candidate]));
|
||||
const alternatives = new Map<string, PreparedCandidate>();
|
||||
const bestByReplacementId = new Map<string, PreparedCandidate>();
|
||||
const fixedIds = new Set(fixedCards.map((card) => card.id));
|
||||
|
||||
for (const finalist of baseFinalists) {
|
||||
const sourceCards = finalist.cards.map((card) => cardById.get(card.id)).filter((card): card is CardRow => Boolean(card));
|
||||
sourceCards.forEach((source, index) => {
|
||||
if (fixedIds.has(source.id)) return;
|
||||
for (const replacement of fiveStarCards) {
|
||||
if (replacement.characterId !== source.characterId || replacement.id === source.id) continue;
|
||||
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
|
||||
const key = combinationKey(cards);
|
||||
if (selected.has(key) || alternatives.has(key)) continue;
|
||||
const prepared = prepareCandidate(cards);
|
||||
alternatives.set(key, prepared);
|
||||
const previous = bestByReplacementId.get(replacement.id);
|
||||
if (!previous || prepared.heuristic > previous.heuristic) bestByReplacementId.set(replacement.id, prepared);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
|
||||
right.heuristic - left.heuristic || right.skillPotential - left.skillPotential || right.overallPower - left.overallPower);
|
||||
const chosenAlternatives = new Map<string, PreparedCandidate>();
|
||||
for (const candidate of bestByReplacementId.values()) {
|
||||
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||
}
|
||||
for (const candidate of rankedAlternatives) {
|
||||
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||
if (chosenAlternatives.size >= ALTERNATIVE_EXACT_FINALIST_LIMIT) break;
|
||||
}
|
||||
for (const candidate of chosenAlternatives.values()) {
|
||||
selected.set(combinationKey(candidate.cards), candidate);
|
||||
}
|
||||
return Array.from(selected.values());
|
||||
}
|
||||
|
||||
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
|
||||
@@ -497,9 +578,7 @@
|
||||
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
||||
};
|
||||
});
|
||||
const response = await fetch(chartMetadata.path);
|
||||
if (!response.ok) throw new Error('기준 조합의 정확 채보 데이터를 불러오지 못했습니다.');
|
||||
const chart = await response.json() as ExactSongChart;
|
||||
const chart = await loadExactSongChart(chartMetadata.path);
|
||||
baselineOverallPower = power.overallPower;
|
||||
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
|
||||
baselineWorker.postMessage({
|
||||
@@ -549,7 +628,7 @@
|
||||
};
|
||||
|
||||
groups.forEach((candidates, workerIndex) => {
|
||||
const parallelWorker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
||||
const parallelWorker = createScoreOptimizerWorker();
|
||||
parallelWorkers.push(parallelWorker);
|
||||
parallelWorker.onmessage = (event) => {
|
||||
if (failed) return;
|
||||
@@ -607,12 +686,10 @@
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
const finalists = selectExactFinalists(prepared);
|
||||
const finalists = addAlternativeVariantFinalists(selectExactFinalists(prepared));
|
||||
exactCombinationCount = finalists.length;
|
||||
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
|
||||
const response = await fetch(selectedChart.path);
|
||||
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
|
||||
const chart = await response.json() as ExactSongChart;
|
||||
const chart = await loadExactSongChart(selectedChart.path);
|
||||
searchPhase = 'exact';
|
||||
if (maximizeCompute) runParallelExact(finalists, chart);
|
||||
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
|
||||
@@ -632,7 +709,45 @@
|
||||
|
||||
function resultCardName(cardId: string): string {
|
||||
const card = cardById.get(cardId);
|
||||
return card ? cardLabel(card) : cardId;
|
||||
return card ? `${card.name}${card.versionName ? ` · ${card.versionName}` : ''}` : cardId;
|
||||
}
|
||||
|
||||
function resultCardAwakening(cardId: string): number {
|
||||
const card = cardById.get(cardId);
|
||||
return card ? awakeningForCard(card) : 0;
|
||||
}
|
||||
|
||||
function displayedCardAwakening(cardId: string, isBaseline: boolean): number {
|
||||
if (!isBaseline) return resultCardAwakening(cardId);
|
||||
const index = baselineState?.cardIds.indexOf(cardId) ?? -1;
|
||||
return index >= 0 ? baselineState?.awakenings[index] ?? 0 : 0;
|
||||
}
|
||||
|
||||
function displayedCompositionKey(ranking: DisplayRankedResult): string {
|
||||
return ranking.result.order
|
||||
.map((cardId) => `${cardId}:${displayedCardAwakening(cardId, ranking.isBaseline)}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function buildDisplayedRankings(
|
||||
rankings: readonly RankedResult[],
|
||||
baseline: DisplayRankedResult | null
|
||||
): DisplayRankedResult[] {
|
||||
if (rankings.length === 0) return [];
|
||||
const unique = new Map<string, DisplayRankedResult>();
|
||||
for (const ranking of rankings) {
|
||||
const display = { ...ranking, isBaseline: false };
|
||||
unique.set(displayedCompositionKey(display), display);
|
||||
}
|
||||
if (baseline) {
|
||||
// 카드와 개화가 같다면 실제 배치 분석값인 기준 조합을 대표 결과로 사용한다.
|
||||
unique.set(displayedCompositionKey(baseline), baseline);
|
||||
}
|
||||
return Array.from(unique.values())
|
||||
.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||
right.result.expectedScore - left.result.expectedScore || Number(right.isBaseline) - Number(left.isBaseline))
|
||||
.slice(0, 10);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -687,10 +802,13 @@
|
||||
</div>
|
||||
<label>자동 후보 기수 보너스 덮어쓰기<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:change={(event) => updateCandidateGenerationBonus(event.currentTarget.value)}></label>
|
||||
</div>
|
||||
<p class="candidate-guide">기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.</p>
|
||||
<p class="candidate-guide">
|
||||
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
|
||||
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
|
||||
</p>
|
||||
<div class="fixed-fields">
|
||||
<div class="fixed-column-headings">
|
||||
<span></span><span></span><span></span>
|
||||
<span>고정 멤버</span><span>개화</span><span>빈도 감소 고정</span>
|
||||
<div><span>파란 노드 전체</span><span class="generation-heading">기수 보너스<br>덮어쓰기</span></div>
|
||||
</div>
|
||||
{#each fixedCardIds as selectedId, slot}
|
||||
@@ -702,7 +820,6 @@
|
||||
</select>
|
||||
</label>
|
||||
<div class="fixed-awakening">
|
||||
<span>개화</span>
|
||||
<div class="awakening-stepper">
|
||||
<button type="button" disabled={!selectedId || fixedAwakenings[slot] <= 0} aria-label={`${slot + 1}번 고정 카드 개화 낮추기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] - 1)}>‹</button>
|
||||
<strong>{fixedAwakenings[slot]}</strong>
|
||||
@@ -710,7 +827,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed-reduction">
|
||||
<span>빈도 감소 고정</span>
|
||||
<div class="reduction-options">
|
||||
{#each REDUCTION_LOCK_OPTIONS as reduction}
|
||||
<button type="button" disabled={!selectedId} class:active={fixedCooldownReductions[slot] === reduction} aria-pressed={fixedCooldownReductions[slot] === reduction} on:click={() => updateFixedReduction(slot, reduction)}>{reduction === null ? '미사용' : `${reduction}%`}</button>
|
||||
@@ -749,22 +865,24 @@
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="result-title">
|
||||
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · 상위 {rankedResults.length}개</strong>{/if}</div>
|
||||
{#if rankedResults.length > 0}
|
||||
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
|
||||
{#if displayedRankings.length > 0}
|
||||
<div class="ranking-list">
|
||||
{#each rankedResults as ranking, rank}
|
||||
<details class="ranking-card" open={rank === 0}>
|
||||
{#each displayedRankings as ranking, rank}
|
||||
<details class:baseline-ranking={ranking.isBaseline} class="ranking-card" open={rank === 0}>
|
||||
<summary>
|
||||
<span class="rank-number">{rank + 1}위</span>
|
||||
<span class="rank-members">
|
||||
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
|
||||
{#each ranking.result.order as cardId, memberIndex}
|
||||
<span class:added-member={!fixedCardIds.includes(cardId)}>
|
||||
{resultCardName(cardId)}
|
||||
<span class:added-member={!ranking.isBaseline && !fixedCardIds.includes(cardId)}>
|
||||
<small class="member-awakening">{displayedCardAwakening(cardId, ranking.isBaseline)}개화</small>
|
||||
<span class="member-name">{resultCardName(cardId)}</span>
|
||||
</span>
|
||||
{#if memberIndex < ranking.result.order.length - 1}<i aria-hidden="true">·</i>{/if}
|
||||
{/each}
|
||||
</span>
|
||||
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span>
|
||||
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span>
|
||||
</summary>
|
||||
<div class="ranking-detail">
|
||||
<AnalysisResultView
|
||||
@@ -772,7 +890,7 @@
|
||||
durationSeconds={selectedChart?.durationSeconds ?? 0}
|
||||
cardName={resultCardName}
|
||||
instanceId={`combination-${rank}`}
|
||||
baseline={comparableToBaseline ? baselineResult : null}
|
||||
baseline={comparableToBaseline && !ranking.isBaseline ? baselineResult : null}
|
||||
showScoreSummary={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -786,10 +904,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: 46px 0 80px; }
|
||||
header { margin-bottom: 28px; }
|
||||
h1 { margin: 0; color: #f3f5f8; font-size: clamp(28px, 5vw, 42px); letter-spacing: -.045em; }
|
||||
.page-description { margin: 10px 0 0; color: #929cab; font-size: 14px; line-height: 1.6; }
|
||||
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
|
||||
header { margin-bottom: var(--page-header-bottom); }
|
||||
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
|
||||
.page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; }
|
||||
.baseline-guide { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border-radius: 8px; color: #8d96a5; background: #1b2029; font-size: 13px; line-height: 1.55; }
|
||||
.baseline-guide strong { color: #dba0b9; }
|
||||
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
|
||||
@@ -815,12 +933,12 @@
|
||||
.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 > 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; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
|
||||
.fixed-awakening { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.fixed-reduction { grid-template-columns: 88px minmax(0, 1fr); }
|
||||
.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; }
|
||||
@@ -861,15 +979,20 @@
|
||||
.ranking-list { display: grid; gap: 10px; margin-top: 18px; }
|
||||
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
|
||||
.ranking-card[open] { border-color: #55404f; background: #15151b; }
|
||||
.ranking-card.baseline-ranking { border-color: #665061; background: #1b171d; }
|
||||
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(170px, 15vw, 230px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
|
||||
.ranking-card summary::-webkit-details-marker { display: none; }
|
||||
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
|
||||
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
|
||||
.rank-number { display: grid; place-items: center; width: 48px; height: 34px; border-radius: 8px; color: #171019; background: #ed8bb5; font-size: 14px; font-weight: 900; }
|
||||
.rank-members { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: #dce1e8; font-size: 13px; font-weight: 800; line-height: 1.55; }
|
||||
.rank-members > span { white-space: nowrap; }
|
||||
.rank-members > span { display: inline-grid; justify-items: center; gap: 1px; white-space: nowrap; }
|
||||
.member-awakening { color: #8d96a5; font-size: 10px; font-weight: 750; line-height: 1.2; }
|
||||
.member-name { line-height: 1.35; }
|
||||
.rank-members .added-member .member-awakening { color: #c987a4; }
|
||||
.rank-members > i { flex: 0 0 auto; color: #596270; font-style: normal; }
|
||||
.rank-members .added-member { color: #f3a5c6; }
|
||||
.baseline-result-label { padding: 3px 7px; border: 1px solid #6a5060; border-radius: 999px; color: #e7b2ca; background: #2a2028; font-size: 10px; font-weight: 850; }
|
||||
.rank-metric { display: grid; gap: 4px; }
|
||||
.rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; }
|
||||
.rank-metric strong { color: #f0a0c2; font-size: 18px; line-height: 1.2; }
|
||||
@@ -887,16 +1010,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
main { width: calc(100% - 24px); padding-top: 28px; }
|
||||
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
|
||||
.panel { padding: 17px; }
|
||||
.song-fields { grid-template-columns: 1fr 100px; }
|
||||
.candidate-fields { grid-template-columns: 1fr; }
|
||||
.fixed-column-headings { grid-template-columns: minmax(0, 1fr) 102px; }
|
||||
.fixed-column-headings > span { display: none; }
|
||||
.fixed-column-headings > div { grid-column: 1 / -1; }
|
||||
.fixed-column-headings { display: none; }
|
||||
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
|
||||
.fixed-reduction, .fixed-overrides { grid-column: 1 / -1; }
|
||||
.fixed-awakening { grid-template-columns: 1fr; }
|
||||
.leader-fields, .scores { grid-template-columns: 1fr; }
|
||||
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
|
||||
.baseline-members { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user