@@ -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 : 46 px 0 80 px ; }
header { margin-bottom : 28 px ; }
h1 { margin : 0 ; color : #f3f5f8 ; font-size : clamp ( 28 px , 5 vw , 42 px ) ; letter-spacing : -.045 em ; }
. page-description { margin : 10 px 0 0 ; color : #929cab ; font-size : 14 px ; 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 80 px ; }
header { margin-bottom : var ( -- page - header - bottom ) ; }
h1 { margin : 0 ; color : #f3f5f8 ; font-size : var ( -- page - title - size ); line-height : 1.15 ; letter-spacing : -.045 em ; }
. page-description { margin : 10 px 0 0 ; color : #929cab ; font-size : var ( -- page - description - size ) ; line-height : 1.6 ; }
. baseline-guide { width : fit-content ; margin : 10 px 0 0 ; padding : 8 px 11 px ; border-radius : 8 px ; color : #8d96a5 ; background : #1b2029 ; font-size : 13 px ; line-height : 1.55 ; }
. baseline-guide strong { color : #dba0b9 ; }
. panel { margin-top : 16 px ; padding : 24 px ; border : 1 px solid #2a303a ; border-radius : 16 px ; background : #171b22 ; }
@@ -815,12 +933,12 @@
. fixed-fields { display : grid ; gap : 9 px ; margin-top : 16 px ; }
. fixed-guide { margin : 8 px 0 0 ; color : #737d8d ; font-size : 12 px ; line-height : 1.5 ; }
. fixed-column-headings { display : grid ; grid-template-columns : minmax ( 320 px , 1 fr ) clamp ( 120 px , 11 vw , 170 px ) clamp ( 304 px , 26 vw , 390 px ) clamp ( 180 px , 16 vw , 240 px ); gap : 9 px ; margin-bottom : -3 px ; }
. fixed-column-headings > span { align-self : center ; color : #8f98a8 ; font-size : 12 px ; font-weight : 800 ; text-align : center ; }
. fixed-column-headings > span : first-child { text-align : left ; }
. fixed-column-headings > div { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 5 px ; color : #8f98a8 ; font-size : 12 px ; font-weight : 800 ; text-align : center ; }
. generation-heading { line-height : 1.25 ; white-space : nowrap ; }
. fixed-field { display : grid ; grid-template-columns : minmax ( 320 px , 1 fr ) clamp ( 120 px , 11 vw , 170 px ) clamp ( 304 px , 26 vw , 390 px ) clamp ( 180 px , 16 vw , 240 px ); gap : 9 px ; }
. fixed-awakening , . fixed-reduction { display : grid ; align-self : end ; align-items : center ; height : 42 px ; gap : 7 px ; color : #8f98a8 ; font-size : 14 px ; font-weight : 750 ; }
. fixed-awakening { grid-template-columns : 32 px minmax ( 0 , 1 fr ); }
. fixed-reduction { grid-template-columns : 88 px minmax ( 0 , 1 fr ); }
. fixed-awakening , . fixed-reduction { display : grid ; align-self : end ; align-items : center ; height : 42 px ; }
. awakening-stepper { display : grid ; grid-template-columns : 30 px minmax ( 0 , 1 fr ) 30 px ; align-items : center ; height : 42 px ; overflow : hidden ; border : 1 px solid #343c49 ; border-radius : 9 px ; background : #11151b ; }
. awakening-stepper button { align-self : stretch ; padding : 0 ; border : 0 ; color : #c1c8d3 ; background : #202630 ; font-size : 22 px ; cursor : pointer ; }
. awakening-stepper strong { color : #e8ebf1 ; font-size : 14 px ; text-align : center ; }
@@ -861,15 +979,20 @@
. ranking-list { display : grid ; gap : 10 px ; margin-top : 18 px ; }
. ranking-card { overflow : hidden ; border : 1 px solid #303744 ; border-radius : 12 px ; 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 : 60 px minmax ( 0 , 1 fr ) clamp ( 170 px , 15 vw , 230 px ); align-items : center ; gap : 18 px ; min-height : 80 px ; padding : 12 px 46 px 12 px 18 px ; cursor : pointer ; list-style : none ; }
. ranking-card summary :: -webkit-details-marker { display : none ; }
. ranking-card summary :: after { content : '⌄' ; position : absolute ; top : 50 % ; right : 15 px ; color : #858f9f ; font-size : 16 px ; transform : translateY ( -50 % ); transition : transform .15 s ; }
. ranking-card [ open ] summary :: after { transform : translateY ( -50 % ) rotate ( 180 deg ); }
. rank-number { display : grid ; place-items : center ; width : 48 px ; height : 34 px ; border-radius : 8 px ; color : #171019 ; background : #ed8bb5 ; font-size : 14 px ; font-weight : 900 ; }
. rank-members { display : flex ; align-items : center ; flex-wrap : wrap ; gap : 6 px ; min-width : 0 ; color : #dce1e8 ; font-size : 13 px ; font-weight : 800 ; line-height : 1.55 ; }
. rank-members > span { white-space : nowrap ; }
. rank-members > span { display : inline - grid ; justify-items : center ; gap : 1 px ; white-space : nowrap ; }
. member-awakening { color : #8d96a5 ; font-size : 10 px ; 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 : 3 px 7 px ; border : 1 px solid #6a5060 ; border-radius : 999 px ; color : #e7b2ca ; background : #2a2028 ; font-size : 10 px ; font-weight : 850 ; }
. rank-metric { display : grid ; gap : 4 px ; }
. rank-metric small { color : #9ca5b3 ; font-size : 13 px ; font-style : normal ; font-weight : 750 ; }
. rank-metric strong { color : #f0a0c2 ; font-size : 18 px ; line-height : 1.2 ; }
@@ -887,16 +1010,13 @@
}
@ media ( max-width : 700px ) {
main { width : calc ( 100 % - 24 px ); padding-top : 28 px ; }
main { width : calc ( 100 % - 24 px ); padding-top : var ( -- page - content - top ) ; }
. panel { padding : 17 px ; }
. song-fields { grid-template-columns : 1 fr 100 px ; }
. candidate-fields { grid-template-columns : 1 fr ; }
. fixed-column-headings { grid-template-columns : minmax ( 0 , 1 fr ) 102 px ; }
. 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 , 1 fr ) 102 px ; }
. fixed-reduction , . fixed-overrides { grid-column : 1 / -1 ; }
. fixed-awakening { grid-template-columns : 1 fr ; }
. leader-fields , . scores { grid-template-columns : 1 fr ; }
. outfit-effect { grid-template-columns : 1 fr ; gap : 5 px ; }
. baseline-members { grid-template-columns : 1 fr ; }