feat(copy): 속성 복사 항목을 선택한 부재에 해당하는 것만 표시

- 부재별 복사 항목 대응표(MEMBER_COPY_ITEMS)를 core/copy-targets.ts에 두고 테스트 추가
  코핑 → 폭·길이, 확대기초·말뚝 → 말뚝 길이·열별 본수, 지반 → 위치·지하수위, 기둥·받침 → 없음
- 항목은 선택된 부재(state.member) 기준으로 그려지고, 체크 상태는 부재를 바꿔도 유지
- 기둥·받침은 "복사 항목 없음"을 표시하고 적용 버튼을 비활성화
- ①은 복사 패널 안의 부재 선택으로, ②는 부재 목록으로 같은 부재를 고름

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-27 16:31:38 +09:00
1 parent ccc1f494bc
commit 3a16ee59da
4 files changed
+87 -34

No files matched your search

+5 -17
View File
@@ -91,23 +91,11 @@
</section>
<section>
<h3>3 · 복사할 항목</h3>
<ul class="cp-items">
<li><span class="cp-group">코핑</span>
<label class="chk"><input type="checkbox" data-item="coping-width" />폭 B</label>
<label class="chk"><input type="checkbox" data-item="coping-length" />길이 L <span class="muted small">교각마다 다름 · 기본 해제</span></label>
</li>
<li><span class="cp-group">지반</span>
<label class="chk"><input type="checkbox" data-item="ground-position" />위치 · 지하수위</label>
</li>
<li><span class="cp-group">확대기초 · 말뚝</span>
<label class="chk"><input type="checkbox" data-item="pile-length" />말뚝 길이 <span class="muted small">지반마다 다름 · 기본 해제</span></label>
<label class="chk"><input type="checkbox" data-item="pile-rows" />열별 본수 <span class="muted small">기초 크기마다 다름 · 기본 해제</span></label>
<label class="chk disabled"><input type="checkbox" disabled />기초 형상 · 치수 · 버림 <span class="muted small">예정</span></label>
</li>
<li><span class="cp-group">기둥 · 받침 · 철근배근</span>
<label class="chk disabled"><input type="checkbox" disabled />기둥 단면 · 지름 D, 받침·받침블럭, 배근 <span class="muted small">예정</span></label>
</li>
</ul>
<div id="cp-member-row" class="cp-tools" hidden>
<label class="small">부재 <select id="cp-member"></select></label>
</div>
<div id="cp-items" class="cp-chips"></div>
<p id="cp-none" class="muted small" hidden>이 부재는 복사 항목이 없습니다. 기둥·받침은 예정입니다.</p>
<p class="muted small">복사하지 않는 값: 좌표 · IP · 받침 EL · 코핑 상면 EL · 기초 하단 EL · 지반고 · 기둥 높이 · 말뚝 선단 EL · 계단 구간</p>
</section>
</div>
+17
View File
@@ -1,3 +1,6 @@
import type { CopyItem } from './copy';
import type { MemberId } from './shape-options';
/** One pier as a target of 속성 복사. */
export interface CopyCandidate {
name: string;
@@ -26,3 +29,17 @@ export function selectedTargets(piers: CopyCandidate[], sourceName: string, same
.filter((c) => c.selectable && ticked.has(c.name))
.map((c) => c.name);
}
/** 부재 → 복사 항목. A member with no copy items (기둥, 받침) copies nothing yet. */
export const MEMBER_COPY_ITEMS: Record<MemberId, CopyItem[]> = {
bearing: [],
coping: ['coping-width', 'coping-length'],
column: [],
footing: ['pile-length', 'pile-rows'],
ground: ['ground-position'],
};
/** The copy items for the selected 부재. */
export function copyItemsFor(member: MemberId): CopyItem[] {
return MEMBER_COPY_ITEMS[member];
}
+40 -16
View File
@@ -8,8 +8,8 @@ import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/valid
import { buildTemplateWorkbook } from './core/template';
import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles';
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
import { copyCandidates, selectedTargets, type CopyCandidate } from './core/copy-targets';
import { applyCopy, COPY_ITEMS, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
import { copyCandidates, copyItemsFor, selectedTargets, type CopyCandidate } from './core/copy-targets';
import { pileGeometry } from './core/geometry';
import { rebarSummary, type RebarRow } from './core/rebar/summary';
import { shearSegments } from './core/rebar/coping';
@@ -644,10 +644,37 @@ function selectedCopyTargets(): Pier[] {
return state.piers.filter((p) => names.has(p.input.name));
}
/** Copy items ticked by the user, limited to the items of the selected 부재. */
function chosenCopyItems(): CopyItem[] {
return Array.from(document.querySelectorAll<HTMLInputElement>('#copy-panel input[data-item]'))
.filter((i) => i.checked)
.map((i) => i.dataset.item as CopyItem);
return copyItemsFor(state.member).filter((item) => copyItemChoice.has(item));
}
/** The items ticked by the user; kept across 부재 changes. */
let copyItemChoice = new Set<CopyItem>(DEFAULT_COPY_ITEMS);
/** Shows the copy items of the selected 부재 only. A 부재 without items shows a note instead. */
function renderCopyItems(): void {
const box = $('cp-items');
box.replaceChildren();
const select = $<HTMLSelectElement>('cp-member');
if (select.options.length === 0) for (const m of MEMBERS) select.appendChild(new Option(m.label, m.id));
const items = copyItemsFor(state.member);
for (const item of items) {
const label = el('label', undefined, 'chk');
const check = el('input');
check.type = 'checkbox';
check.dataset.item = item;
check.checked = copyItemChoice.has(item);
check.addEventListener('change', () => {
if (check.checked) copyItemChoice.add(item);
else copyItemChoice.delete(item);
renderCopyPreview();
});
label.append(check, document.createTextNode(COPY_ITEMS[item].label));
box.appendChild(label);
}
$('cp-none').hidden = items.length > 0;
select.value = state.member;
}
function renderCopyTargets(): void {
@@ -703,23 +730,18 @@ function renderCopyPreview(): void {
const apply = $<HTMLButtonElement>('cp-apply');
apply.textContent = selected.length === 0 ? '대상을 고르세요' : `${selected.map((t) => t.input.name).join(', ')} ${selected.length}기에만 적용`;
apply.disabled = applied === 0;
if (items.length === 0) apply.textContent = '복사 항목 없음';
}
/** The inline 속성 복사 panel: source is the 기준 pier; targets are ticked inside the panel only. */
function renderCopyPanel(): void {
const src = activePier();
$('cp-source').textContent = src ? `${src.input.name} · ${src.input.type} · ${src.input.foundationType}` : '교각을 선택하세요';
renderCopyItems();
renderCopyTargets();
renderCopyPreview();
}
/** Default copy items, set once at start-up. */
function resetCopyItems(): void {
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-panel input[data-item]')) {
i.checked = (DEFAULT_COPY_ITEMS as string[]).includes(i.dataset.item ?? '');
}
}
function applyCopyPanel(): void {
const src = activePier();
if (!src) return;
@@ -1064,6 +1086,8 @@ function wireRebarPicking(): void {
function placeCopyPanel(): void {
const host = state.stage === 'plan' ? $('member-regen').parentElement : $('edit-regen').parentElement;
if (host) host.appendChild($('copy-panel'));
// ① has no 부재 list of its own, so the copy panel carries the selector; ② uses its 부재 list.
$('cp-member-row').hidden = state.stage !== 'shape';
}
function setStage(stage: Stage): void {
@@ -3079,9 +3103,10 @@ $<HTMLButtonElement>('cp-clear').addEventListener('click', () => {
renderCopyTargets();
renderCopyPreview();
});
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-panel input[data-item]')) {
i.addEventListener('change', renderCopyPreview);
}
$<HTMLSelectElement>('cp-member').addEventListener('change', (ev) => {
state.member = (ev.target as HTMLSelectElement).value as MemberId;
renderAll();
});
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyPanel);
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
@@ -3238,7 +3263,6 @@ function restorePanel(id: string): void {
}
restorePanel('parts-info');
restorePanel('color-info');
resetCopyItems();
$<HTMLButtonElement>('all-toggle').addEventListener('click', () => {
state.allView = !state.allView;
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { copyCandidates, selectedTargets, type CopyCandidate } from '../src/core/copy-targets';
import { copyCandidates, copyItemsFor, selectedTargets, type CopyCandidate } from '../src/core/copy-targets';
const piers: CopyCandidate[] = [
{ name: 'P1', type: 'T형', hasModel: true },
@@ -39,3 +39,27 @@ describe('selectedTargets (선택한 대상만)', () => {
expect(selectedTargets(piers, 'P3', true, picked)).toEqual([]);
});
});
describe('copyItemsFor (부재 선택에 해당하는 복사 항목)', () => {
it('coping copies only the coping items', () => {
expect(copyItemsFor('coping')).toEqual(['coping-width', 'coping-length']);
});
it('확대기초 · 말뚝 copies only the pile items', () => {
expect(copyItemsFor('footing')).toEqual(['pile-length', 'pile-rows']);
});
it('지반 copies only the ground position', () => {
expect(copyItemsFor('ground')).toEqual(['ground-position']);
});
it('기둥 and 받침 have no copy items yet', () => {
expect(copyItemsFor('column')).toEqual([]);
expect(copyItemsFor('bearing')).toEqual([]);
});
it('every copy item belongs to exactly one member', () => {
const all = (['bearing', 'coping', 'column', 'footing', 'ground'] as const).flatMap((m) => copyItemsFor(m));
expect([...all].sort()).toEqual(['coping-length', 'coping-width', 'ground-position', 'pile-length', 'pile-rows']);
});
});