diff --git a/index.html b/index.html index 4da964f..e94837a 100644 --- a/index.html +++ b/index.html @@ -246,6 +246,61 @@ + +
+

속성 복사

+

한 교각의 설정을 여러 교각에 한 번에 적용합니다

+ +
+
+
+
+

1 · 원본 교각

+

+

현재 입력값 기준 (미반영 변경 포함)

+
+
+

2 · 복사할 항목

+
    +
  • 코핑 + + +
  • +
  • 확대기초 · 말뚝 + + +
  • +
  • 기둥 · 받침 · 철근배근 + +
  • +
+

복사하지 않는 값: 좌표 · IP · 받침 EL · 코핑 상면 EL · 기초 하단 EL · 지반고 · 기둥 높이 · 말뚝 선단 EL · 계단 구간

+
+
+

3 · 대상 교각

+ +
    +
    +
    +
    +
    +

    4 · 적용 미리보기

    + + + +
    대상형식바뀌는 값건너뜀내용
    + +

    적용하면 대상 교각마다 "변경 n건"이 표시되고, 입력 수정 카드에서 항목별로 되돌릴 수 있습니다.

    +
    +
    +
    + +
    + diff --git a/src/main.ts b/src/main.ts index 7765eaf..919617b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; import { buildTemplateWorkbook } from './core/template'; import { EDITABLE_FIELDS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes'; +import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup'; import type { Issue, PierInput, PierType } from './core/types'; @@ -266,6 +267,7 @@ function renderHeader(): void { regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성'; $('bulk-count').textContent = `선택 ${checked}기`; $('bulk-regen').disabled = checked === 0; + $('bulk-copy').disabled = !activePier(); } // --- results panel (시안 ① 입력값 · 계산 결과) ---------------------------------------- @@ -369,6 +371,114 @@ function renderAll(): void { scheduleBackup(); } +// --- 속성 복사 (시안 ⑥, plan C8) --------------------------------------------------------- +/** Names of the pier targets ticked in the copy dialog. */ +let copySelection = new Set(); + +function copyTargets(): Pier[] { + const src = activePier(); + const sameType = $('cp-same-type').checked; + return state.piers.filter((p) => p !== src && (!sameType || p.input.type === src?.input.type)); +} + +/** Targets that are both visible in the dialog and ticked. Hidden targets are never changed. */ +function selectedCopyTargets(): Pier[] { + return copyTargets().filter((t) => copySelection.has(t.input.name)); +} + +function chosenCopyItems(): CopyItem[] { + return Array.from(document.querySelectorAll('#copy-dialog input[data-item]')) + .filter((i) => i.checked) + .map((i) => i.dataset.item as CopyItem); +} + +function renderCopyTargets(): void { + const list = $('cp-targets'); + list.replaceChildren(); + for (const t of copyTargets()) { + const li = el('li'); + const check = el('input'); + check.type = 'checkbox'; + check.checked = copySelection.has(t.input.name); + check.setAttribute('aria-label', `${t.input.name} 대상`); + check.addEventListener('change', () => { + if (check.checked) copySelection.add(t.input.name); + else copySelection.delete(t.input.name); + renderCopyPreview(); + }); + li.append(check, el('span', `${t.input.name} · ${t.input.type} · ${t.input.foundationType}`)); + list.appendChild(li); + } +} + +function renderCopyPreview(): void { + const src = activePier(); + const items = chosenCopyItems(); + const body = $('cp-preview').tBodies[0]; + body.replaceChildren(); + let applied = 0; + let skippedTotal = 0; + const selected = selectedCopyTargets(); + for (const t of selected) { + if (!src) break; + const plan = planCopy(src.draft, t.draft, items); + const labels = plan.changes.map((c) => `${EDITABLE_FIELDS.find((f) => f.key === c.key)!.label} ${c.from} → ${c.to}`); + const skipped = plan.skipped.map((k) => k.reason); + skippedTotal += skipped.length; + if (plan.changes.length) applied++; + const row = el('tr'); + row.append( + el('td', t.input.name), + el('td', t.input.type), + el('td', String(plan.changes.length)), + el('td', String(skipped.length)), + el('td', [...labels, ...skipped].join(' · ') || '바뀌는 값 없음'), + ); + body.appendChild(row); + } + const n = selected.length; + $('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`; + const apply = $('cp-apply'); + apply.textContent = `대상 ${applied}기에 적용`; + apply.disabled = applied === 0; +} + +function openCopyDialog(): void { + const src = activePier(); + if (!src) return; + $('cp-source').textContent = `${src.input.name} · ${src.input.type} · ${src.input.foundationType}`; + for (const i of document.querySelectorAll('#copy-dialog input[data-item]')) { + i.checked = (DEFAULT_COPY_ITEMS as string[]).includes(i.dataset.item ?? ''); + } + copySelection = new Set(state.piers.filter((p) => p.checked && p !== src).map((p) => p.input.name)); + renderCopyTargets(); + renderCopyPreview(); + $('copy-dialog').showModal(); +} + +function applyCopyDialog(): void { + const src = activePier(); + if (!src) return; + const items = chosenCopyItems(); + const regen = $('cp-regen').checked; + let applied = 0; + let changes = 0; + const targets = new Set(selectedCopyTargets().map((t) => t.input.name)); + state.piers = state.piers.map((t) => { + if (t === src || !targets.has(t.input.name)) return t; + const plan = planCopy(src.draft, t.draft, items); + if (plan.changes.length === 0) return t; + applied++; + changes += plan.changes.length; + const next = { ...t, draft: applyCopy(t.draft, plan) }; + return regen ? regenerateEntry(next) : next; + }); + $('copy-dialog').close(); + setupScene(); + renderAll(); + setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`); +} + // --- input cards and change tracking (plan B1 · C2) ------------------------------------- function pendingTotal(): number { return state.piers.filter((p) => p.checked).reduce((n, p) => n + pendingKeys(p).length, 0); @@ -781,6 +891,18 @@ $('dlg-cancel').addEventListener('click', () => $('dlg-apply').addEventListener('click', applyImport); $('regen').addEventListener('click', regenerateChecked); $('bulk-regen').addEventListener('click', regenerateChecked); +$('bulk-copy').addEventListener('click', openCopyDialog); +$('cp-same-type').addEventListener('change', () => { + renderCopyTargets(); + renderCopyPreview(); +}); +for (const i of document.querySelectorAll('#copy-dialog input[data-item]')) { + i.addEventListener('change', renderCopyPreview); +} +$('cp-regen').addEventListener('change', renderCopyPreview); +$('cp-apply').addEventListener('click', applyCopyDialog); +$('cp-cancel').addEventListener('click', () => $('copy-dialog').close()); +$('cp-close').addEventListener('click', () => $('copy-dialog').close()); $('sv-save').addEventListener('click', confirmSaveDialog); $('sv-cancel').addEventListener('click', () => $('save-dialog').close()); $('sv-close').addEventListener('click', () => $('save-dialog').close()); diff --git a/src/style.css b/src/style.css index ada7491..fdfe8f9 100644 --- a/src/style.css +++ b/src/style.css @@ -644,4 +644,11 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; .views .btn[aria-pressed='true'] { background: #1f5fa8; color: #ffffff; font-weight: 700; } .viewer-head { gap: 8px; } .edit-card h3 { font-size: 12px; color: #55606e; } +/* 속성 복사 (시안 ⑥) */ +.cp-items li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 6px 0; border-bottom: 1px solid #e6eaef; } +.cp-group { min-width: 110px; font-size: 12px; font-weight: 700; color: #55606e; } +.cp-targets { list-style: none; padding: 0; margin: 6px 0; max-height: 240px; overflow-y: auto; } +.cp-targets li { display: flex; align-items: center; gap: 8px; min-height: 36px; } +.chk.disabled { opacity: 0.5; } +#cp-preview td, #cp-preview th { font-size: 12px; }