feat: improve board review and connection optimization assumptions
Publish GitHub Pages output / publish (push) Successful in 45s

This commit is contained in:
dal4segno committed 2026-10-06 12:12:09 +09:00
1 parent 0b794edc80
commit d18972382a
8 files changed
+277 -29

No files matched your search

+61 -19
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { refineBoardConnectionSongBonus } from '$lib/engine/board-connection-song-refinement';
import { assumeTeamBlueBoards, missingBoardOpenings, type BoardOpeningRequirement } from '$lib/engine/board-blue-assumption.ts';
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
import { onMount, tick } from 'svelte';
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
@@ -35,6 +36,8 @@
type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
type ConnectionOptimizationProposal = {
assumedBlue: boolean;
openingRequirements: BoardOpeningRequirement[];
placements: BoardCards;
assignments: { memberId: string; socketId: string; previousCardId: string | null; cardId: string | null }[];
beforeExpected: number;
@@ -215,6 +218,7 @@
let errorMessage = '';
let analysisReady = false;
let connectionOptimizationRunning = false;
let assumeBlueForConnections = false;
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
let connectionOptimizationError = '';
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
@@ -605,8 +609,8 @@
};
}
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride) {
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride, boards = effectiveMemberBoards) {
const localBoardEffects = calculateBoardEffects(boardSummary, boards, selectedSourceCards, leaderCharacterId, chartSummary, {
placements,
cards: connectionCards
}, {
@@ -684,19 +688,28 @@
if (resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
const analysisResult = result;
const chartSummary = selectedChart;
const assumedBlue = assumeBlueForConnections;
const optimizationInputKey = currentAnalysisKey;
const optimizationBoards = assumeTeamBlueBoards(boardSummary, effectiveMemberBoards, selectedSourceCards.map((card) => card.characterId), assumedBlue);
const openingRequirements = assumedBlue ? missingBoardOpenings(boardSummary, memberBoards, optimizationBoards) : [];
const scoreContext = (placements: BoardCards) => connectionScoreContext(placements, chartSummary, songScoreBonusOverride, optimizationBoards);
connectionOptimizationRunning = true;
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
connectionOptimizationError = '';
connectionOptimizationProposal = null;
try {
const chart = await loadExactSongChart(chartSummary.path);
const beforeContext = connectionScoreContext(boardCards, chartSummary);
const emptyContext = connectionScoreContext({}, chartSummary);
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => analysisResult.order.map((cardId) =>
const beforeContext = scoreContext(boardCards);
const emptyContext = scoreContext({});
const baseline = assumedBlue ? optimizeExactOrderAndCooldowns(beforeContext.cards, chart, {
perfectNoteScore: perfectNoteScoreFromPower(beforeContext.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: beforeContext.boardEffects.directSongScoreBonusPercent
}) : analysisResult;
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => baseline.order.map((cardId) =>
context.cards.find((card) => card.id === cardId)!
);
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
orderedCards(context), analysisResult.cooldownReductions, chart, {
orderedCards(context), baseline.cooldownReductions, chart, {
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
}
@@ -715,7 +728,7 @@
const key = `${memberId}:${socketId}:${effectKey}`;
const cached = placementMetricCache.get(key);
if (cached) return cached;
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
const context = scoreContext({ [memberId]: { [socketId]: card.id } });
const metrics = {
score: referenceScore(context) - emptyScore,
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
@@ -725,7 +738,7 @@
};
const activeConnectionSlots = boardMembers.flatMap((member) => {
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
const active = new Set(effectiveMemberBoards[member.id] ?? []);
const active = new Set(optimizationBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
return positions.filter((position) => active.has(position.groupId) && position.groupId.startsWith('S'))
@@ -746,7 +759,7 @@
}
connectionOptimizationProgress = { stage: '추천 배치 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
const optimized = optimizeBoardConnectionCards(boardSummary, optimizationBoards, candidateCards, {
currentPlacements: boardCards,
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
preservePlacementOnTie: () => true,
@@ -754,13 +767,13 @@
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
});
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, effectiveMemberBoards, candidateCards, optimized.placements, {
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, optimizationBoards, candidateCards, optimized.placements, {
currentPlacements: boardCards,
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
song: chartSummary,
songBonusEnabled: songScoreBonusOverride === null,
evaluate: (placements) => {
const context = connectionScoreContext(placements, chartSummary);
const context = scoreContext(placements);
return { score: referenceScore(context), activationRateUp: totalActivationRateUp(context) };
},
onProgress: async (checks) => {
@@ -770,16 +783,16 @@
});
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
const afterContext = scoreContext(optimized.placements);
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
});
const afterActivationRateUp = totalActivationRateUp(afterContext);
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
if (after.theoreticalMaxScore < baseline.theoreticalMaxScore) {
throw new Error(`동일 보드 조건의 기존 배치 최고점(${baseline.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
}
if (after.theoreticalMaxScore === analysisResult.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
if (after.theoreticalMaxScore === baseline.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
throw new Error('최고점이 같고 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
}
const changedAssignments = boardMembers.flatMap((member) => {
@@ -794,12 +807,15 @@
cardId: afterPlacements[socketId] ?? null
}));
});
if (optimizationInputKey !== currentAnalysisKey) throw new Error('탐색 중 입력 조건이 변경되었습니다. 다시 분석한 뒤 연결 카드 최적화를 실행해 주세요.');
connectionOptimizationProposal = {
assumedBlue,
openingRequirements,
placements: optimized.placements,
assignments: changedAssignments,
beforeExpected: analysisResult.expectedScore,
beforeExpected: baseline.expectedScore,
afterExpected: after.expectedScore,
beforeMaximum: analysisResult.theoreticalMaxScore,
beforeMaximum: baseline.theoreticalMaxScore,
afterMaximum: after.theoreticalMaxScore,
beforeActivationRateUp,
afterActivationRateUp
@@ -813,7 +829,7 @@
}
async function applyConnectionOptimization(): Promise<void> {
if (resultStale || !connectionOptimizationProposal) return;
if (resultStale || !connectionOptimizationProposal || connectionOptimizationProposal.openingRequirements.length > 0) return;
connectionOptimizationSnapshot = Object.fromEntries(Object.entries(boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
saveBoardCardPlacements(connectionOptimizationProposal.placements);
connectionOptimizationProposal = null;
@@ -1226,6 +1242,10 @@
</div>
</div>
<p>단, 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 해당 카드의 기존 배치를 우선 유지합니다.</p>
<label class="connection-assumption-option">
<input type="checkbox" bind:checked={assumeBlueForConnections} disabled={running || connectionOptimizationRunning} on:change={clearConnectionOptimizationProposal}>
<span><strong>파란 보드 개방 가정</strong><small>편성 5명의 전체 파란 노드와 파란 영역 연결 소켓을 임시 개방해 추천합니다. 다른 멤버·다른 색상 보드는 유지하며 저장된 보드는 변경하지 않습니다.</small></span>
</label>
{#if connectionOptimizationProgress}
<div class="connection-optimizer-progress" aria-live="polite">
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>
@@ -1235,6 +1255,21 @@
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
{#if connectionOptimizationProposal}
<div class="connection-proposal" aria-live="polite">
{#if connectionOptimizationProposal.assumedBlue}
<p class="connection-assumption-notice"><strong>개방 가정 결과</strong> · 아래 전후 점수는 모두 동일한 개방 가정 기준입니다. 현재 성장 대비 상승량이 아니라 카드 재배치 효과를 비교합니다.</p>
{#if connectionOptimizationProposal.openingRequirements.length > 0}
<div class="connection-opening-requirements">
<strong>적용 전 필요한 보드 개방</strong>
<p>아래 노드는 실제 저장 보드에서 미개방 상태입니다. 사용자 데이터에서 실제 개방 상태를 갱신한 뒤 다시 분석·최적화해 주세요. 지금은 추천만 확인할 수 있습니다.</p>
<ul>{#each connectionOptimizationProposal.openingRequirements as requirement}
<li><strong>{boardMembers.find((member) => member.id === requirement.memberId)?.name ?? requirement.memberId}</strong> · 능력치 노드 {requirement.nodeIds.length}개 · 연결 소켓 {requirement.socketIds.length}개
<details><summary>필요 노드 확인</summary><p>{requirement.nodeIds.join(', ')}{requirement.socketIds.length ? ` · 연결: ${requirement.socketIds.map((id) => `${connectionSocketName(requirement.memberId, id)} (${id})`).join(', ')}` : ''}</p></details>
</li>
{/each}</ul>
<a href="/data/boards" target="_blank" rel="noreferrer">사용자 보드 열기</a>
</div>
{/if}
{/if}
<div class="connection-score-comparison">
<span>예상 점수 <strong>{connectionOptimizationProposal.beforeExpected.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterExpected.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterExpected >= connectionOptimizationProposal.beforeExpected ? '+' : ''}{(connectionOptimizationProposal.afterExpected - connectionOptimizationProposal.beforeExpected).toLocaleString('ko-KR')}</b></span>
<span>이론상 고점 <strong>{connectionOptimizationProposal.beforeMaximum.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterMaximum.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterMaximum >= connectionOptimizationProposal.beforeMaximum ? '+' : ''}{(connectionOptimizationProposal.afterMaximum - connectionOptimizationProposal.beforeMaximum).toLocaleString('ko-KR')}</b></span>
@@ -1249,7 +1284,7 @@
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? `${previousCard.rarity}★ ${cardLabel(previousCard)}` : '비어 있음'} → {card ? `${card.rarity}★ ${cardLabel(card)}` : '배치 해제'}</strong></span>
{/each}
</div>
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
<button type="button" class="apply-connection" disabled={resultStale || running || connectionOptimizationProposal.openingRequirements.length > 0} on:click={applyConnectionOptimization}>{connectionOptimizationProposal.openingRequirements.length > 0 ? '필요 보드 개방 후 적용 가능' : '변경 배치를 적용하고 다시 분석'}</button>
{:else}
<p class="connection-no-changes">이번 보정에서는 기존 배치보다 좋은 배치를 찾지 못했습니다.</p>
{/if}
@@ -1506,6 +1541,13 @@
.connection-optimizer button { min-height: 38px; padding: 8px 12px; border: 1px solid #46574c; border-radius: 8px; color: #becbc2; background: #202a23; font-weight: 800; cursor: pointer; }
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }
.connection-optimizer button:disabled { opacity: .55; cursor: default; }
.connection-assumption-option { display: flex; align-items: flex-start; gap: 10px; margin-top: 14px; padding: 12px; border: 1px solid #46574c; border-radius: 9px; }
.connection-assumption-option span { display: grid; gap: 5px; }
.connection-assumption-option small { color: #a9b8ad; line-height: 1.5; }
.connection-assumption-notice, .connection-opening-requirements { padding: 12px; border: 1px solid #967445; border-radius: 8px; background: #29251c; color: #eed6ac; }
.connection-opening-requirements li { margin: 8px 0; }
.connection-opening-requirements details p { overflow-wrap: anywhere; }
.connection-opening-requirements a { color: #a6ddba; }
.connection-optimizer-progress { display: grid; gap: 7px; margin-top: 14px; padding: 10px 12px; border: 1px solid #314238; border-radius: 9px; background: #111a15; }
.connection-optimizer-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.connection-optimizer-progress strong { color: #b9dac5; font-size: 13px; }
+16 -2
View File
@@ -172,6 +172,7 @@
let activeSearchChart: ExactSongChart | null = null;
let primaryRankings: RankedResult[] = [];
let searchPhase: 'idle' | 'screening' | 'exact' | 'probability' = 'idle';
$: searchStep = searchPhase === 'screening' ? 1 : searchPhase === 'probability' ? 4 : exactPass === 'refinement' ? 3 : 2;
let screeningProgress = 0;
let errorMessage = '';
let ready = false;
@@ -1379,7 +1380,14 @@
</label>
<p id="result-limit-guide" class="candidate-guide">다음 탐색부터 적용합니다. 결과 수를 늘리면 달성 확률 계산 시간이 늘어납니다. 분석한 조합이 적으면 선택한 수보다 적게 표시됩니다.</p>
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
{#if running}
<div class="search-step-status" role="status" aria-live="polite">
<strong class="search-step-badge" aria-label={`현재 단계 ${searchStep}/4`}>{searchStep}<span>/4</span></strong>
<div><small>현재 진행 단계</small><strong>{['후보 선별', '선별 후보 평가', '확장 후보 검증', '달성 확률 계산'][searchStep - 1]}</strong></div>
</div>
<p class="candidate-guide">퍼센트는 현재 단계의 진행률입니다. 확장 후보가 없으면 3단계는 건너뜁니다.</p>
{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `${exactPass === 'refinement' ? '확장 후보 검증' : '선별 후보 평가'} ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<label class="compute-option">
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
<span aria-hidden="true"></span>
@@ -1391,7 +1399,7 @@
</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}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
<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 userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if}
{#if !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if}
{#if transferError}<p class="error" role="alert">{transferError}</p>{/if}
@@ -1545,6 +1553,12 @@
.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: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.search-step-status { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 14px 16px; border: 1px solid #b65c84; border-radius: 10px; background: #30212c; }
.search-step-badge { display: flex; align-items: baseline; gap: 3px; flex-shrink: 0; padding: 8px 12px; border-radius: 8px; background: #ed8bb5; color: #24131e; font-size: 26px; font-weight: 900; line-height: 1.2; font-variant-numeric: tabular-nums; }
.search-step-badge span { font-size: 16px; }
.search-step-status > div { display: grid; gap: 3px; }
.search-step-status small { color: #d6a1ba; font-size: 11px; font-weight: 700; }
.search-step-status > div > strong { color: #ffe4f0; font-size: 16px; }
.search:hover:not(:disabled) { background: #f2a0c2; }
.search:disabled { opacity: .55; cursor: default; }
.owned-cards-option, .compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
+23 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { boardMemberOptions, unplacedOwnedCards } from '$lib/engine/board-editor-options.ts';
import { onMount, tick } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json';
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
@@ -113,6 +114,8 @@
let boardDragStart = { x: 0, y: 0, left: 0, top: 0 };
let selectedSocketGroupId = '';
let boardCardSearch = '';
let boardMemberSort: 'default' | 'most' | 'least' = 'default';
let unplacedOwnedOnly = false;
let automaticPlacementSnapshot: BoardCardPlacements | null = null;
let automaticPlacementResult: AutomaticPlacementResult[] = [];
let savedAnalysis: SavedAnalysisState | null = null;
@@ -136,6 +139,7 @@
$: filteredFiveStarCards = filteredCards.filter((card) => card.rarity === 5);
$: filteredFourStarCards = filteredCards.filter((card) => card.rarity === 4);
$: selectedBoardMember = boardMembers.find((member) => member.id === selectedMemberId) ?? null;
$: sortedBoardMembers = boardMemberOptions(boardMembers, boardModels, userData.memberBoards, boardMemberSort);
$: selectedActiveBoardNodes = userData.memberBoards[selectedMemberId] ?? [];
$: selectedActiveBoardNodeCount = new Set(selectedActiveBoardNodes.filter((nodeId) => nodeId !== centerNodeId())).size;
$: selectedBoardNodeCount = selectedBoardMember
@@ -148,7 +152,7 @@
selectedActiveBoardNodes,
selectedBoardMember?.positionGroupId ?? ''
);
$: filteredConnectCards = connectCards.filter((card) => {
$: filteredConnectCards = unplacedOwnedCards(connectCards, userData.collection, userData.boardCards, unplacedOwnedOnly).filter((card) => {
return cardMatchesSearch(card, boardCardSearch);
});
$: allBoardMaterialTotals = boardMaterialTotals(userData.memberBoards);
@@ -381,10 +385,10 @@
}
function selectAdjacentBoardMember(offset: -1 | 1) {
if (boardMembers.length < 2) return;
const currentIndex = boardMembers.findIndex((member) => member.id === selectedMemberId);
const nextIndex = (Math.max(0, currentIndex) + offset + boardMembers.length) % boardMembers.length;
selectedMemberId = boardMembers[nextIndex].id;
if (sortedBoardMembers.length < 2) return;
const currentIndex = sortedBoardMembers.findIndex((member) => member.id === selectedMemberId);
const nextIndex = (Math.max(0, currentIndex) + offset + sortedBoardMembers.length) % sortedBoardMembers.length;
selectedMemberId = sortedBoardMembers[nextIndex].id;
finishBoardMemberChange();
}
@@ -1400,11 +1404,19 @@
<label class="member-select">
<span>멤버 선택</span>
<select bind:value={selectedMemberId} on:change={finishBoardMemberChange}>
{#each boardMembers as member}<option value={member.id}>{member.name}</option>{/each}
{#each sortedBoardMembers as member}<option value={member.id}>{member.name} · {member.openedCount}개 개방</option>{/each}
</select>
<small><kbd>A</kbd> 이전 멤버 · <kbd>D</kbd> 다음 멤버</small>
</label>
<div class="member-navigation" aria-label="멤버 보드 이동">
<label class="member-select">
<span>멤버 정렬</span>
<select bind:value={boardMemberSort}>
<option value="default">기본순</option>
<option value="most">보드 개방 많은 순</option>
<option value="least">보드 개방 적은 순</option>
</select>
</label>
<button type="button" on:click={() => selectAdjacentBoardMember(-1)} aria-label="이전 멤버 보드">‹ 이전</button>
<button type="button" on:click={() => selectAdjacentBoardMember(1)} aria-label="다음 멤버 보드">다음 ›</button>
</div>
@@ -1502,11 +1514,13 @@
</div>
<p class="card-picker-note">배치한 카드의 범위에 포함된 활성 노드만 강화됩니다. 같은 카드를 다시 선택하면 기존 위치에서 이곳으로 이동합니다.</p>
<input class="card-picker-search" type="search" bind:value={boardCardSearch} placeholder="멤버 또는 버전 검색" aria-label="연결 카드 검색" />
<label class="card-picker-filter"><input type="checkbox" bind:checked={unplacedOwnedOnly} /> 미배치 보유 카드만</label>
{#if selectedBoardCardPlacements[selectedSocketGroupId]}
{@const currentCard = connectCardById.get(selectedBoardCardPlacements[selectedSocketGroupId])}
<div class="current-placement"><span>현재 배치</span><strong>{currentCard ? cardLabel(currentCard) : selectedBoardCardPlacements[selectedSocketGroupId]}</strong><button type="button" on:click={removeBoardCard}>배치 해제</button></div>
{/if}
<div class="connect-card-list">
{#if filteredConnectCards.length === 0}<p class="card-picker-note">조건에 맞는 연결 카드가 없습니다.</p>{/if}
{#each filteredConnectCards as card}
{@const effect = selectedConnectEffect(card)}
{@const placement = cardPlacementLabel(card.id)}
@@ -1645,13 +1659,14 @@
.board-member-settings { order: 1; margin-bottom: 0; }
.board-tools { order: 0; }
.board-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; }
.card-picker-filter { display: flex; align-items: center; gap: 8px; margin: 12px 0; color: #c9d0dc; font-size: 13px; }
.board-guidance { margin: 0 0 12px; padding: 12px 14px; border: 1px solid #4a3b46; border-radius: 10px; color: #c8b7c1; background: rgb(239 139 182 / 7%); font-size: 13px; line-height: 1.6; }
.member-select { display: grid; gap: 5px; width: min(360px, 100%); }
.member-select span { color: #818b9c; font-size: 12px; font-weight: 750; }
.member-select select { height: 38px; padding: 0 10px; border: 1px solid #394252; border-radius: 8px; color: #dfe3ea; background: #12171f; }
.member-select small { color: #6f7888; font-size: 11px; }
.member-select kbd { display: inline-grid; min-width: 18px; height: 18px; padding: 0 4px; place-items: center; border: 1px solid #465064; border-bottom-width: 2px; border-radius: 4px; color: #c9d0dc; background: #171d27; font: 700 10px/1 Pretendard, "Noto Sans KR", sans-serif; }
.member-navigation { display: flex; gap: 7px; }
.member-navigation { display: flex; align-items: flex-end; gap: 7px; }
.member-navigation button { min-width: 82px; height: 38px; padding: 0 12px; border: 1px solid #465064; border-radius: 8px; color: #d5dbe4; background: #202733; font-size: 13px; font-weight: 850; cursor: pointer; }
.member-navigation button:hover { border-color: #d96b9a; color: #f4b0cc; }
.board-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 20px 0 10px; }
@@ -1852,6 +1867,7 @@
.board-heading { align-items: stretch; flex-direction: column; }
.member-select { width: 100%; }
.member-navigation { display: grid; grid-template-columns: 1fr 1fr; }
.member-navigation .member-select { grid-column: 1 / -1; }
.member-navigation button { width: 100%; min-height: 44px; }
.board-toolbar { align-items: flex-start; gap: 10px; }
.automatic-placement { align-items: stretch; flex-direction: column; gap: 10px; }