feat(view): 형상계획 ② screen skeleton with five members (plan.md C1)

- ① and ② are switched from the stage nav. ② shows the pier list with change
  counts, the five members (교량받침, 코핑, 기둥, 확대기초·말뚝, 지반·지층) with
  a change count each, a member card, and a change list with per-item revert.
- The member card shows 기본 and 선택 summaries separately. Only members with
  existing inputs get fields (코핑 폭·길이, 말뚝 길이); the others show values
  and a note that their inputs come in a later step. Unselected members' inputs
  are not shown.
- The field rows are shared between ① and ②.
- Added a global [hidden] rule so hidden screens stay hidden.
- Not verified in a browser.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-22 10:07:34 +09:00
1 parent f75431d898
commit 56362570ec
3 files changed
+160 -21

No files matched your search

+30 -3
View File
@@ -14,8 +14,8 @@
<span id="project-line" class="proj">프로젝트 미지정</span>
</div>
<nav class="steps" aria-label="단계">
<span class="step current" aria-current="step">① 형상 생성 <em id="step1-status">미생성</em></span>
<span class="step" aria-disabled="true">② 형상계획 <em id="step2-status">예정</em></span>
<button id="nav-shape" class="step current" type="button" aria-current="step">① 형상 생성 <em id="step1-status">미생성</em></button>
<button id="nav-plan" class="step" type="button">② 형상계획 <em id="step2-status">예정</em></button>
<span class="step" aria-disabled="true">③ 철근배근 <em>예정</em></span>
</nav>
<div class="actions">
@@ -35,7 +35,7 @@
<button id="discard-backup" class="btn small" type="button">버리기</button>
</div>
<main class="grid">
<main class="grid" id="view-shape">
<aside class="panel piers" aria-labelledby="pier-title">
<p id="pier-title" class="lbl">교각 목록</p>
<div class="pier-tools">
@@ -106,6 +106,33 @@
</aside>
</main>
<main class="grid plan" id="view-plan" hidden>
<aside class="panel piers" aria-labelledby="plan-pier-title">
<p id="plan-pier-title" class="lbl">교각 목록</p>
<div class="phead" aria-hidden="true"><span></span><span>교각</span><span>변경</span></div>
<ul id="plan-pier-items" class="pier-items"></ul>
<p class="lbl members-title">부재 · 5개</p>
<ul id="members" class="members"></ul>
</aside>
<section class="panel viewer" aria-labelledby="member-title">
<div class="viewer-head">
<h2 id="member-title">코핑</h2>
</div>
<p id="member-summary" class="num"></p>
<div id="member-fields"></div>
<p id="member-note" class="muted small"></p>
<button id="member-regen" class="btn primary" type="button" disabled>다시 생성 · 변경 0</button>
</section>
<aside class="panel results" aria-labelledby="changes-title">
<h2 id="changes-title">기본값 대비 변경</h2>
<ul id="changes" class="issues"></ul>
<h2>알림</h2>
<ul id="plan-issues" class="issues"></ul>
</aside>
</main>
</div>
<dialog id="import-dialog" aria-labelledby="dlg-title">
+118 -18
View File
@@ -41,6 +41,16 @@ const VIEWS: { kind: ViewKind; label: string }[] = [
const TYPES: PierType[] = ['T형', '2주형', '3주형'];
const CRS = '국토좌표계 (x = 동, y = 북)';
/** 시안 ② 부재 목록 (spec §5.2). `keys` are the inputs that exist today; the rest show values only. */
type MemberId = 'bearing' | 'coping' | 'column' | 'footing' | 'ground';
const MEMBERS: { id: MemberId; label: string; summary: string; keys: EditableKey[]; note: string }[] = [
{ id: 'bearing', label: '교량받침·받침블럭·솔플레이트', summary: '배치 분리', keys: [], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' },
{ id: 'coping', label: '코핑', summary: '교좌면 직각 · 하면 직선', keys: ['copingWidthMm', 'copingLengthMm'], note: '폭과 길이를 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' },
{ id: 'column', label: '기둥', summary: '원형 · 중실', keys: [], note: '기둥 지름 입력은 다음 단계에서 추가합니다. 높이와 위치는 계산값입니다.' },
{ id: 'footing', label: '확대기초 · 말뚝', summary: '직사각 · 강관', keys: ['pileLengthMm'], note: '말뚝 길이를 입력합니다. 열별 본수는 다음 단계에서 추가합니다.' },
{ id: 'ground', label: '지반·지층', summary: '육상 · 표시만', keys: [], note: '지반고는 입력값을 표시만 합니다. 지층 입력은 다음 단계에서 추가합니다.' },
];
interface Pending {
fileName: string;
entries: Pier[];
@@ -55,6 +65,10 @@ const state = {
visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.parts).map((p) => [p, true])) as Record<PartName, boolean>,
scene: null as PierScene | null,
project: { name: '', bridge: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo,
/** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */
stage: 'shape' as 'shape' | 'plan',
/** Member card selected on ② (시안 ② 부재 목록). */
member: 'coping' as MemberId,
/** True once the project has a file on disk (first save via 시안 ⑤, or an opened file). Later saves go straight to it. */
savedOnce: false,
filter: 'all' as 'all' | PierType,
@@ -217,10 +231,19 @@ function renderPierList(): void {
opt.textContent = `${opt.value === 'all' ? '전체' : opt.value} (${count})`;
}
const list = $<HTMLUListElement>('pier-items');
list.replaceChildren();
renderPierRows($<HTMLUListElement>('pier-items'), 'shape');
renderPierRows($<HTMLUListElement>('plan-pier-items'), 'plan');
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
const allChecked = visible.length > 0 && visible.every((p) => p.checked);
$<HTMLButtonElement>('select-all').textContent = allChecked ? '선택 해제' : '전체 선택';
}
/** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */
function renderPierRows(list: HTMLUListElement, mode: 'shape' | 'plan'): void {
list.replaceChildren();
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');
@@ -240,15 +263,14 @@ function renderPierList(): void {
setupScene();
renderAll();
});
const badge = pier.model
? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge')
: el('span', '오류', 'badge error');
const badge = mode === 'plan'
? el('span', `변경 ${overrideKeys(pier).length}`, 'badge')
: pier.model
? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge')
: el('span', '오류', 'badge error');
li.append(check, name, badge);
list.appendChild(li);
}
const allChecked = visible.length > 0 && visible.every((p) => p.checked);
$<HTMLButtonElement>('select-all').textContent = allChecked ? '선택 해제' : '전체 선택';
}
function renderHeader(): void {
@@ -368,6 +390,7 @@ function renderAll(): void {
renderPierList();
renderPanels();
renderEditCard();
renderPlan();
scheduleBackup();
}
@@ -519,19 +542,31 @@ function regenerateOne(name: string): void {
}
function renderEditCard(): void {
const box = $<HTMLDivElement>('edit-fields');
box.replaceChildren();
const pier = activePier();
const btn = $<HTMLButtonElement>('edit-regen');
if (!pier) {
$<HTMLDivElement>('edit-fields').replaceChildren();
$('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.';
btn.textContent = '다시 생성 · 변경 0';
btn.disabled = true;
return;
}
const status = fieldRows($<HTMLDivElement>('edit-fields'), pier, EDITABLE_FIELDS.map((f) => f.key));
$('edit-summary').textContent = status.pending.length
? `미반영 변경 ${status.pending.length}건. 3D 그림은 생성본을 유지합니다.`
: status.overrides.length
? `기본값과 ${status.overrides.length}건 다릅니다. 생성본이 최신입니다.`
: '기본값과 같습니다.';
btn.textContent = `다시 생성 · 변경 ${status.pending.length}`;
btn.disabled = status.pending.length === 0;
}
/** One row per input key: value in the draft, the imported default beside it, and a revert button when overridden. */
function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { overrides: EditableKey[]; pending: EditableKey[] } {
box.replaceChildren();
const overrides = overrideKeys(pier);
const pending = pendingKeys(pier);
for (const f of EDITABLE_FIELDS) {
for (const f of EDITABLE_FIELDS.filter((x) => keys.includes(x.key))) {
const changed = overrides.includes(f.key);
const row = el('div', undefined, changed ? 'edit-row changed' : 'edit-row');
const id = `edit-${f.key}`;
@@ -552,13 +587,72 @@ function renderEditCard(): void {
}
box.appendChild(row);
}
$('edit-summary').textContent = pending.length
? `미반영 변경 ${pending.length}건. 3D 그림은 생성본을 유지합니다.`
: overrides.length
? `기본값과 ${overrides.length}건 다릅니다. 생성본이 최신입니다.`
: '기본값과 같습니다.';
btn.textContent = `다시 생성 · 변경 ${pending.length}`;
btn.disabled = pending.length === 0;
return { overrides, pending };
}
// --- ② 형상계획 (plan.md C1) -------------------------------------------------------------
function setStage(stage: 'shape' | 'plan'): void {
state.stage = stage;
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
$<HTMLElement>('view-plan').hidden = stage !== 'plan';
$<HTMLButtonElement>('nav-shape').setAttribute('aria-current', String(stage === 'shape'));
$<HTMLButtonElement>('nav-plan').setAttribute('aria-current', String(stage === 'plan'));
renderAll();
}
function renderPlan(): void {
const pier = activePier();
const members = $<HTMLUListElement>('members');
members.replaceChildren();
for (const m of MEMBERS) {
const li = el('li');
const btn = el('button', m.label, 'member');
btn.type = 'button';
btn.setAttribute('aria-pressed', String(m.id === state.member));
const changed = pier ? overrideKeys(pier).filter((k) => m.keys.includes(k)).length : 0;
btn.append(el('span', changed ? `변경 ${changed}` : '변경 0', 'badge'));
btn.addEventListener('click', () => {
state.member = m.id;
renderAll();
});
li.appendChild(btn);
members.appendChild(li);
}
const member = MEMBERS.find((m) => m.id === state.member)!;
$('member-title').textContent = member.label;
$('member-summary').textContent = `기본: ${member.summary} · 선택: ${member.summary}`;
$('member-note').textContent = member.note;
const regen = $<HTMLButtonElement>('member-regen');
const fields = $<HTMLDivElement>('member-fields');
if (!pier) {
fields.replaceChildren();
regen.textContent = '다시 생성 · 변경 0';
regen.disabled = true;
} else {
fieldRows(fields, pier, member.keys);
regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`;
regen.disabled = pendingKeys(pier).length === 0;
}
const changes = $<HTMLUListElement>('changes');
changes.replaceChildren();
const pending = pier ? pendingKeys(pier) : [];
for (const key of pier ? overrideKeys(pier) : []) {
const f = EDITABLE_FIELDS.find((x) => x.key === key)!;
const li = el('li', `${f.label}: 기본 ${pier!.defaults[key]} → 현재 ${pier!.draft[key]}${pending.includes(key) ? ' (미반영)' : ''}`);
const revert = el('button', '되돌리기', 'btn small');
revert.type = 'button';
revert.addEventListener('click', () => revertField(pier!.input.name, key));
li.appendChild(revert);
changes.appendChild(li);
}
if (changes.childElementCount === 0) changes.appendChild(el('li', '기본값과 같습니다.'));
const issues = $<HTMLUListElement>('plan-issues');
issues.replaceChildren();
for (const i of pier?.issues ?? []) issues.appendChild(el('li', `${i.code} ${i.message}`));
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
}
// --- project file (plan B3·B4·B5) --------------------------------------------------------
@@ -892,6 +986,12 @@ $<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
$<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shape'));
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
$<HTMLButtonElement>('member-regen').addEventListener('click', () => {
const pier = activePier();
if (pier) regenerateOne(pier.input.name);
});
$<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
renderCopyTargets();
renderCopyPreview();
+12
View File
@@ -652,3 +652,15 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.chk.disabled { opacity: 0.5; }
#cp-preview td, #cp-preview th { font-size: 12px; }
/* 형상계획 ② (plan.md C1) */
[hidden] { display: none !important; }
.members { list-style: none; padding: 0; margin: 0; }
.members li { margin: 2px 0; }
.members-title { margin-top: 14px; }
.member { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #18212b; font-size: 13px; text-align: left; cursor: pointer; }
.member[aria-pressed='true'] { background: #e3edf8; color: #123e70; font-weight: 700; }
#member-fields { margin-top: 10px; }
#member-summary { color: #55606e; margin: 4px 0 8px; }
.plan .viewer { flex: 1 1 380px; }
.plan .results { flex: 1 1 300px; max-width: 360px; }