feat(view): attribute-copy dialog (시안 ⑥, plan C8)
- 속성 복사… opens the four-step dialog: source (current inputs), items, targets (same-type filter on by default), and a preview with changed values and skipped items per target. Apply changes only the visible, ticked targets. - Items that the model does not have yet (기둥, 받침, 배근, 기초 형상) are shown as 예정 and disabled. Coping length and pile length are off by default. - Option: regenerate the targets right after applying (default on); otherwise they show as 재생성 필요 through the normal change tracking. - Not verified in a browser. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4accf1f3be
commit
f75431d898
3 files changed
+184
No files matched your search
+122
@@ -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}기`;
|
||||
$<HTMLButtonElement>('bulk-regen').disabled = checked === 0;
|
||||
$<HTMLButtonElement>('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<string>();
|
||||
|
||||
function copyTargets(): Pier[] {
|
||||
const src = activePier();
|
||||
const sameType = $<HTMLInputElement>('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<HTMLInputElement>('#copy-dialog input[data-item]'))
|
||||
.filter((i) => i.checked)
|
||||
.map((i) => i.dataset.item as CopyItem);
|
||||
}
|
||||
|
||||
function renderCopyTargets(): void {
|
||||
const list = $<HTMLUListElement>('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 = $<HTMLTableElement>('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 = $<HTMLButtonElement>('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<HTMLInputElement>('#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();
|
||||
$<HTMLDialogElement>('copy-dialog').showModal();
|
||||
}
|
||||
|
||||
function applyCopyDialog(): void {
|
||||
const src = activePier();
|
||||
if (!src) return;
|
||||
const items = chosenCopyItems();
|
||||
const regen = $<HTMLInputElement>('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;
|
||||
});
|
||||
$<HTMLDialogElement>('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 @@ $<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogE
|
||||
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
|
||||
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
|
||||
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
|
||||
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
|
||||
$<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
|
||||
renderCopyTargets();
|
||||
renderCopyPreview();
|
||||
});
|
||||
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
|
||||
i.addEventListener('change', renderCopyPreview);
|
||||
}
|
||||
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
|
||||
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyDialog);
|
||||
$<HTMLButtonElement>('cp-cancel').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
|
||||
$<HTMLButtonElement>('cp-close').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
|
||||
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
|
||||
$<HTMLButtonElement>('sv-cancel').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
|
||||
$<HTMLButtonElement>('sv-close').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user