refactor(view): 교각 목록 체크박스 제거, 속성 복사를 부재별 편집창 하단으로 이동

- 교각 목록의 체크박스·전체 선택·선택 교각 수 막대 제거
- ① 3D: 생성된 교각 전체 (전체 보기), ② 3D: 기준 교각만
- 다시 생성: 변경이 있거나 아직 생성되지 않은 교각만 다시 생성 (변경 교각 다시 생성)
- 속성 복사: 모달 창을 없애고 ① 편집 카드, ② 부재 패널 하단의 인라인 패널로 이동
  대상 교각은 패널 안에서만 고름. 지반 위치·지하수위 항목을 복사 항목에 추가
- 일괄 적용 버튼과 ③ 속성 복사 버튼 제거 (복사는 편집 패널에서)
- 활성 교각 체크 기능(shownNames, overrideTargets) 제거

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-27 16:16:30 +09:00
1 parent 4fc02afb1a
commit 42ae591eba
5 files changed
+113 -262

No files matched your search

-29
View File
@@ -1,29 +0,0 @@
import { overrideKeys, type EditableKey, type PierValues } from './changes';
import type { PierInput } from './types';
/** One list row as the selection rules see it. */
export interface PierSelection {
name: string;
checked: boolean;
hasModel: boolean;
}
/**
* Piers shown in the 3D view: every checked pier that has a model, plus the 기준 pier (the one whose card is open)
* even when it is unchecked. Order follows the list (승인 2026-10-27, 체크 = 활성).
*/
export function shownNames(list: PierSelection[], active: string | null): string[] {
return list.filter((p) => p.hasModel && (p.checked || p.name === active)).map((p) => p.name);
}
/** Piers that receive the 기준 pier's overrides: the checked piers with a model, other than the 기준 pier. */
export function overrideTargets(list: PierSelection[], active: string | null): string[] {
return list.filter((p) => p.checked && p.hasModel && p.name !== active).map((p) => p.name);
}
/** The target input with the source's overridden values copied in; every other value of the target is kept. */
export function applyOverrides(source: PierValues, target: PierInput): PierInput {
const next: PierInput = { ...target };
for (const key of overrideKeys(source) as EditableKey[]) next[key] = source.draft[key] as never;
return next;
}
+48 -110
View File
@@ -7,7 +7,6 @@ import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
import { buildTemplateWorkbook } from './core/template';
import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
import { applyOverrides, overrideTargets, shownNames, type PierSelection } from './core/active-piers';
import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles';
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
import { pileGeometry } from './core/geometry';
@@ -56,7 +55,7 @@ let bearingDrawing: Record<string, DrawingBearing[]> = {};
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
let groundModel: GroundModelFile | null = null;
import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection';
import type { TypeFilter } from './core/selection';
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
@@ -351,21 +350,16 @@ function setView(kind: ViewKind): void {
}
/** 전체 보기 applies on ① 형상 생성 only; ③ and the others keep the single-pier scene. */
function selectionOf(p: Pier): PierSelection {
return { name: p.input.name, checked: p.checked, hasModel: p.model !== null };
}
/** The piers the 3D view shows together: the checked ones plus the 기준 pier (체크 = 활성, 승인 2026-10-27). */
/** The piers the 3D view shows together: all generated piers in ① (전체 보기), the 기준 pier alone in ②. */
function shownPiers(): Pier[] {
const names = new Set(shownNames(state.piers.map(selectionOf), state.active));
return state.piers.filter((p) => names.has(p.input.name));
if (state.stage === 'shape') return state.piers.filter((p) => p.model);
const active = activePier();
return active?.model ? [active] : [];
}
/** Several piers in one frame: ① 전체 보기 (button), or ② when two or more piers are shown. */
/** Several piers in one frame: only ① with 전체 보기 on. ② shows the 기준 pier alone. */
function multiSceneOn(): boolean {
if (state.stage === 'shape') return state.allView;
if (state.stage === 'plan') return shownPiers().length >= 2;
return false;
return state.stage === 'shape' && state.allView;
}
/** Builds the shown piers in one frame. Returns false when none has a model. */
@@ -428,7 +422,6 @@ function renderPierList(): void {
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
$<HTMLButtonElement>('select-all').textContent = allVisibleChecked(state.piers, state.filter) ? '선택 해제' : '전체 선택';
}
/** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */
@@ -437,16 +430,6 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void {
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
for (const pier of visible) {
const li = el('li');
const check = el('input');
check.type = 'checkbox';
check.checked = pier.checked;
check.disabled = !pier.model;
check.setAttribute('aria-label', `${pier.input.name} 선택`);
check.addEventListener('change', () => {
pier.checked = check.checked;
setupScene();
renderAll();
});
// The pier name and type are shown here, since the 3D title band is gone on wide screens (승인 2026-10-23).
const name = el('button', `${pier.input.name} · ${pier.input.type}`, 'pier-name');
name.type = 'button';
@@ -464,7 +447,7 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void {
: pier.model
? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge')
: el('span', '오류', 'badge error');
li.append(check, name, badge);
li.append(name, badge);
list.appendChild(li);
}
}
@@ -478,14 +461,12 @@ function renderHeader(): void {
$('step1-status').textContent = pier?.model ? '생성됨' : '미생성';
renderSaveStatus();
const total = pendingTotal();
const checked = state.piers.filter((p) => p.checked).length;
const targets = generationTargets();
$('step2-status').textContent = total ? `변경 ${total}건 · 재생성 필요` : '예정';
const regen = $<HTMLButtonElement>('regen');
regen.disabled = checked === 0;
regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성';
$('bulk-count').textContent = `선택 ${checked}기`;
$<HTMLButtonElement>('bulk-regen').disabled = checked === 0;
$<HTMLButtonElement>('bulk-copy').disabled = !activePier();
regen.disabled = targets.length === 0;
regen.textContent = targets.length ? `변경 교각 다시 생성 · ${targets.length}기` : '교각 형상 생성';
renderCopyPanel();
}
// --- results panel (시안 ① 입력값 · 계산 결과) ----------------------------------------
@@ -500,8 +481,7 @@ function renderInputTable(): void {
const inp = p.input;
const front = inp.bearings.filter((b) => b.row === '전열').length;
const back = inp.bearings.filter((b) => b.row === '후열').length;
const classes = [p.checked ? 'shown' : '', p.input.name === state.active ? 'active' : ''].filter(Boolean).join(' ');
const tr = el('tr', undefined, classes || undefined);
const tr = el('tr', undefined, p.input.name === state.active ? 'active' : undefined);
tr.tabIndex = 0;
tr.addEventListener('click', () => selectPier(p.input.name));
tr.addEventListener('keydown', (e) => {
@@ -648,7 +628,7 @@ function renderAll(): void {
}
// --- 속성 복사 (시안 ⑥, plan C8) ---------------------------------------------------------
/** Names of the pier targets ticked in the copy dialog. */
/** Names of the pier targets ticked in the copy panel. */
let copySelection = new Set<string>();
function copyTargets(): Pier[] {
@@ -663,7 +643,7 @@ function selectedCopyTargets(): Pier[] {
}
function chosenCopyItems(): CopyItem[] {
return Array.from(document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]'))
return Array.from(document.querySelectorAll<HTMLInputElement>('#copy-panel input[data-item]'))
.filter((i) => i.checked)
.map((i) => i.dataset.item as CopyItem);
}
@@ -719,20 +699,22 @@ function renderCopyPreview(): void {
apply.disabled = applied === 0;
}
function openCopyDialog(): void {
/** The inline 속성 복사 panel: source is the 기준 pier; targets are ticked inside the panel only. */
function renderCopyPanel(): 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));
$('cp-source').textContent = src ? `${src.input.name} · ${src.input.type} · ${src.input.foundationType}` : '교각을 선택하세요';
renderCopyTargets();
renderCopyPreview();
$<HTMLDialogElement>('copy-dialog').showModal();
}
function applyCopyDialog(): void {
/** 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;
const items = chosenCopyItems();
@@ -749,7 +731,6 @@ function applyCopyDialog(): void {
const next = { ...t, draft: applyCopy(t.draft, plan) };
return regen ? regenerateEntry(next) : next;
});
$<HTMLDialogElement>('copy-dialog').close();
setupScene();
renderAll();
setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`);
@@ -757,7 +738,7 @@ function applyCopyDialog(): void {
// --- 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);
return state.piers.reduce((n, p) => n + pendingKeys(p).length, 0);
}
function fieldLabel(key: EditableKey): string {
@@ -812,12 +793,6 @@ function renderEditCard(): void {
: '기본값과 같습니다.';
btn.textContent = `다시 생성 · 변경 ${status.pending.length}`;
btn.disabled = status.pending.length === 0;
// The 기준 pier's overrides can go to the other checked piers; they take effect on 선택 교각 다시 생성.
const targets = overrideTargets(state.piers.map(selectionOf), state.active);
const applyBtn = $<HTMLButtonElement>('edit-apply-active');
applyBtn.hidden = targets.length === 0;
applyBtn.textContent = `활성 교각 ${targets.length}기에 적용`;
applyBtn.disabled = overrideKeys(pier).length === 0;
}
/** One row per input key: value in the draft, the imported default beside it, and a revert button when overridden. */
@@ -1079,10 +1054,17 @@ function wireRebarPicking(): void {
});
}
/** The 속성 복사 panel sits at the bottom of the 부재별 편집창: the ① edit card, or the ② member panel. */
function placeCopyPanel(): void {
const host = state.stage === 'plan' ? $('member-regen').parentElement : $('edit-regen').parentElement;
if (host) host.appendChild($('copy-panel'));
}
function setStage(stage: Stage): void {
// ① opens on 전체 보기 (default). The button is gone, so 전체 보기 returns each time ① is entered.
if (stage === 'shape' && state.stage !== 'shape') state.allView = true;
state.stage = stage;
placeCopyPanel();
// Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout.
if ((allPiers !== null) !== multiSceneOn()) setupScene();
if (state.scene) setRebarView(state.scene, stage === 'rebar');
@@ -1607,9 +1589,6 @@ function renderRebar(): void {
const pier = activePier();
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
renderPierRows($<HTMLUListElement>('rebar-pier-items'), 'rebar');
const checked = state.piers.filter((p) => p.checked).length;
$('rebar-bulk-count').textContent = `선택 ${checked}기`;
$<HTMLButtonElement>('rebar-bulk-copy').disabled = !pier;
const tabs = $<HTMLUListElement>('rebar-tabs');
tabs.replaceChildren();
for (const m of REBAR_MEMBERS) {
@@ -1769,10 +1748,7 @@ function renderQuantityLink(box: HTMLElement, rows: RebarRow[]): void {
/** ④ 재료표: the 철근재료표 of each target pier, bars by 강종 → 직경 → 번호, with subtotals and the totals. */
function quantityTargets(): Pier[] {
const checked = state.piers.filter((p) => p.checked && p.model);
if (checked.length) return checked;
const active = activePier();
return active?.model ? [active] : [];
return state.piers.filter((p) => p.model);
}
/** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */
@@ -2288,16 +2264,6 @@ function setStrataRows(name: string, rows: StrataRow[]): void {
renderAll();
}
/** 일괄 적용: the source's 위치 and 지하수위 go to the checked piers, which are then regenerated. */
function applyGroundPositionTo(source: Pier, targets: Pier[]): void {
for (const target of targets) {
target.draft = applyCopy(target.draft, planCopy(source.draft, target.draft, ['ground-position']));
}
regenerateAllPiers();
renderAll();
setStatus(`${targets.length}기에 위치와 지하수위를 적용했습니다.`);
}
/**
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
* The ground model import and the 지반고 apply stay at the bottom, as before.
@@ -2454,15 +2420,6 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
wrap.appendChild(fig);
}
// 일괄 적용: copies the 위치 and 지하수위 to the other checked piers (속성 복사 item 'ground-position').
const others = state.piers.filter((q) => q.checked && q.input.name !== name);
const batch = el('button', `${name} 외 일괄 대상 ${others.length}기에 적용`, 'btn small');
batch.type = 'button';
batch.title = '위치와 지하수위 설정을 체크한 다른 교각에 적용합니다.';
batch.disabled = others.length === 0;
batch.addEventListener('click', () => applyGroundPositionTo(pier, others));
wrap.appendChild(batch);
// ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button.
wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl'));
@@ -3059,19 +3016,23 @@ async function fetchSample(): Promise<Uint8Array> {
return new Uint8Array(await res.arrayBuffer());
}
function regenerateChecked(): void {
/** Piers that need a (re)build: never generated, or with changes not yet in the 3D model. */
function generationTargets(): Pier[] {
return state.piers.filter((p) => !p.model || pendingKeys(p).length > 0);
}
function regenerateChanged(): void {
const names = new Set(generationTargets().map((p) => p.input.name));
let errors = 0;
let count = 0;
state.piers = state.piers.map((p) => {
if (!p.checked) return p;
count++;
if (!names.has(p.input.name)) return p;
const next = regenerateEntry(p);
if (!next.model) errors++;
return next;
});
setupScene();
renderAll();
setStatus(`${count}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`);
setStatus(`${names.size}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`);
}
// --- events -----------------------------------------------------------------------------
@@ -3079,10 +3040,7 @@ $<HTMLButtonElement>('open-import').addEventListener('click', openImport);
$<HTMLButtonElement>('dlg-close').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
$<HTMLButtonElement>('rebar-bulk-copy').addEventListener('click', openCopyDialog);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChanged);
$<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shape'));
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
@@ -3103,13 +3061,11 @@ $<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
renderCopyTargets();
renderCopyPreview();
});
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-panel 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>('cp-apply').addEventListener('click', applyCopyPanel);
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
$<HTMLButtonElement>('sv-cancel').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
$<HTMLButtonElement>('sv-close').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
@@ -3118,21 +3074,6 @@ $<HTMLButtonElement>('edit-regen').addEventListener('click', () => {
if (pier) regenerateOne(pier.input.name);
});
$<HTMLButtonElement>('edit-apply-active').addEventListener('click', () => {
const base = activePier();
if (!base) return;
const targets = overrideTargets(state.piers.map(selectionOf), state.active);
const keys = overrideKeys(base);
if (targets.length === 0 || keys.length === 0) return;
const labels = keys.map((k) => FIELD_LABELS[k]).join(', ');
const ok = window.confirm(
`${targets.join(', ')}에 기준 ${base.input.name}의 ${keys.length}개 항목(${labels})을 적용합니다.\n적용 후 '선택 교각 다시 생성'을 눌러야 3D에 반영됩니다.`,
);
if (!ok) return;
for (const p of state.piers) if (targets.includes(p.input.name)) p.draft = applyOverrides(base, p.draft);
renderAll();
setStatus(`${targets.length}기에 ${keys.length}개 항목을 적용했습니다.`);
});
$<HTMLInputElement>('project-name').addEventListener('input', updateApply);
$<HTMLButtonElement>('pick-file').addEventListener('click', () => $<HTMLInputElement>('file').click());
@@ -3189,10 +3130,6 @@ $<HTMLSelectElement>('type-filter').addEventListener('change', (ev) => {
state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType;
renderPierList();
});
$<HTMLButtonElement>('select-all').addEventListener('click', () => {
state.piers = setVisibleChecked(state.piers, state.filter, !allVisibleChecked(state.piers, state.filter));
renderAll();
});
$<HTMLButtonElement>('save-project').addEventListener('click', saveProject);
$<HTMLButtonElement>('open-project').addEventListener('click', () => $<HTMLInputElement>('project-file').click());
@@ -3283,6 +3220,7 @@ function restorePanel(id: string): void {
}
restorePanel('parts-info');
restorePanel('color-info');
resetCopyItems();
$<HTMLButtonElement>('all-toggle').addEventListener('click', () => {
state.allView = !state.allView;
+8 -1
View File
@@ -816,8 +816,15 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
#inputs-all { font-size: 12px; width: 100%; }
#inputs-all th, #inputs-all td { white-space: nowrap; padding: 4px 6px; }
#inputs-all tbody tr { cursor: pointer; }
#inputs-all tbody tr.shown td { background: #f4f8fc; }
#inputs-all tbody tr.active td { background: #e3edf8; font-weight: 700; }
/* Import preview: all piers' 받침·IP rows scroll inside the dialog. */
.pv-scroll { max-height: 240px; overflow: auto; }
/* 속성 복사: an inline panel at the bottom of the 부재별 편집창 (was a modal dialog). */
.copy-panel { margin-top: 12px; border-top: 1px solid #d5dde6; padding-top: 10px; }
.copy-panel .copy-head h3 { margin: 0 0 4px; font-size: 13px; }
.copy-panel .dlg-body { display: block; }
.copy-panel .dlg-col + .dlg-col { margin-top: 10px; }
.copy-panel .grid-table { font-size: 12px; display: block; overflow-x: auto; }
.copy-panel .copy-foot { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-top: 10px; }