diff --git a/index.html b/index.html
index d4f0361..0073078 100644
--- a/index.html
+++ b/index.html
@@ -193,6 +193,7 @@
받침 값
+
형상 (벽체·흉벽, 저판, 말뚝)
@@ -204,6 +205,8 @@
+ 부재 · 교대
+
부재 · 5개
diff --git a/src/core/abut/member-list.ts b/src/core/abut/member-list.ts
new file mode 100644
index 0000000..6e220f9
--- /dev/null
+++ b/src/core/abut/member-list.ts
@@ -0,0 +1,66 @@
+import type { WingForm } from './geometry';
+import { ABUT_SHAPE_DEFAULT, type AbutShape } from './shape';
+
+/** 부재 · 교대 list in ② (시안): the card each row opens. */
+export type AbutMemberId = 'bearing' | 'shape' | 'wing' | 'slab' | 'ground';
+
+/** Cards that take input in this step; slab and ground only show their values (phase 1, 2026-11-03). */
+export const ABUT_MEMBER_EDITABLE: Record = {
+ bearing: true,
+ shape: true,
+ wing: true,
+ slab: false,
+ ground: false,
+};
+
+const WING_LABEL: Record = { general: '일반형', short: '단축형', none: '없음' };
+
+/** The subset of an abutment record the list reads. */
+export interface AbutMemberInput {
+ bearings: { seatEl?: number }[];
+ shape?: Partial;
+ wings?: { left: WingForm; right: WingForm };
+ groundEl: number;
+}
+
+export interface AbutMemberRow {
+ id: AbutMemberId;
+ label: string;
+ summary: string;
+ /** True when the value differs from the default (or a 받침 has its own 교좌면 EL). */
+ changed: boolean;
+}
+
+/**
+ * The five rows of the 부재 · 교대 list. `slabWidth` is the computed 접속슬래브 폭 text, passed in
+ * so this stays a pure function of the record.
+ */
+export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMemberRow[] {
+ const shape = { ...ABUT_SHAPE_DEFAULT, ...(rec.shape ?? {}) };
+ const shapeChanged = (Object.keys(ABUT_SHAPE_DEFAULT) as (keyof AbutShape)[]).some(
+ (key) => (rec.shape?.[key] ?? ABUT_SHAPE_DEFAULT[key]) !== ABUT_SHAPE_DEFAULT[key],
+ );
+ const wings = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
+ return [
+ {
+ id: 'bearing',
+ label: '교량받침·받침블럭·솔플레이트',
+ summary: `${rec.bearings.length}개 · z = 패드 상면`,
+ changed: rec.bearings.some((b) => b.seatEl !== undefined),
+ },
+ {
+ id: 'shape',
+ label: '벽체·흉벽',
+ summary: `벽체 두께 ${shape.wallThicknessMm} · 흉벽 ${shape.parapetHeightMm}`,
+ changed: shapeChanged,
+ },
+ {
+ id: 'wing',
+ label: '날개벽',
+ summary: `좌 ${WING_LABEL[wings.left]} · 우 ${WING_LABEL[wings.right]}`,
+ changed: wings.left !== 'general' || wings.right !== 'general',
+ },
+ { id: 'slab', label: '접속슬래브', summary: slabWidth, changed: false },
+ { id: 'ground', label: '지반·지층', summary: `지반고 ${rec.groundEl.toFixed(3)} m`, changed: false },
+ ];
+}
diff --git a/src/main.ts b/src/main.ts
index fcc08b5..0f29089 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -11,6 +11,7 @@ import { ABUT_DETAIL_LABELS, abutInputRows, abutSummaryRows } from './core/abut/
import { applyAbutEdit, applyBearingEdit, applyShapeEdit, ABUT_EDIT_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit';
import { ABUT_SHAPE_DEFAULT, resolveShape } from './core/abut/shape';
import { abutShown, piersShown } from './core/abut/visibility';
+import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/abut/member-list';
import { abutSectionSvg } from './view/abut-section-svg';
import type { WingForm } from './core/abut/geometry';
import { buildMembers } from './core/abut/members';
@@ -129,6 +130,8 @@ const state = {
abutments: [] as AbutRecord[],
/** 교대 편집 카드에서 고른 교대 (2026-11-03). */
abutActive: null as string | null,
+ /** The 부재 · 교대 row whose card is open in ② (2026-11-03). */
+ abutMember: 'bearing' as AbutMemberId,
/** The editable part of the 교대 panel is open (2026-11-03). */
abutEditOpen: false,
/** 교대 기본값 (가져온 그대로), for 기본값 복원. */
@@ -611,7 +614,7 @@ function focusAbut(name: string): void {
/** The 교대 입력 panel slots of ① (replaces the pier info) and ② (under the list). */
const ABUT_SLOTS = {
shape: { root: 'abut-panel', name: 'abut-panel-name', input: 'abut-panel-input', toggle: 'abut-panel-edit-toggle', editor: 'abut-panel-editor', summary: 'abut-panel-summary', basic: 'abut-panel-basic', bearings: 'abut-panel-bearings', shape: 'abut-panel-shape', msg: 'abut-panel-msg', prefix: 'abs' },
- plan: { root: 'abut-panel-plan', name: 'abut-panel-plan-name', input: 'abut-panel-plan-input', toggle: 'abut-panel-plan-edit-toggle', editor: 'abut-panel-plan-editor', summary: 'abut-panel-plan-summary', basic: 'abut-panel-plan-basic', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
+ plan: { wing: 'abut-panel-plan-wing', root: 'abut-panel-plan',name: 'abut-panel-plan-name', input: 'abut-panel-plan-input', toggle: 'abut-panel-plan-edit-toggle', editor: 'abut-panel-plan-editor', summary: 'abut-panel-plan-summary', basic: 'abut-panel-plan-basic', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
} as const;
/** Shows the 교대 입력 in place of the pier info when an abutment is selected (2026-11-03). */
@@ -625,6 +628,44 @@ function renderAbutEditor(): void {
box.hidden = !rec;
if (rec) fillAbutSlot(slot, rec);
}
+ if (rec) {
+ renderAbutMembers(rec);
+ applyAbutMemberCard(rec);
+ } else {
+ $('abut-members').replaceChildren();
+ }
+}
+
+/** The 부재 · 교대 rows in ②: each row's value and the card it opens (2026-11-03, phase 1). */
+function renderAbutMembers(rec: AbutRecord): void {
+ const box = $('abut-members');
+ const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—';
+ box.replaceChildren();
+ for (const row of abutMemberRows(rec, slabWidth)) {
+ const btn = el('button', undefined, 'pier-name');
+ btn.type = 'button';
+ btn.setAttribute('aria-pressed', String(row.id === state.abutMember));
+ btn.append(el('span', row.label), el('span', row.summary, 'muted small'));
+ if (row.changed) btn.append(el('span', '변경', 'badge'));
+ btn.addEventListener('click', () => {
+ state.abutMember = row.id;
+ state.abutEditOpen = true;
+ renderAll();
+ });
+ const li = el('li');
+ li.append(btn);
+ box.append(li);
+ }
+}
+
+/** Shows only the card of the open row in the ② 교대 editor; the basic items stay visible. */
+function applyAbutMemberCard(rec: AbutRecord): void {
+ const member = state.abutMember;
+ const group = (id: string) => $(id).closest('details') ?? $(id);
+ group('abut-panel-plan-bearings').hidden = member !== 'bearing';
+ group('abut-panel-plan-shape').hidden = member !== 'shape';
+ $('abut-panel-plan-wing').hidden = member !== 'wing';
+ $('abut-panel-plan-msg').textContent = ABUT_MEMBER_EDITABLE[member] ? '' : `${rec.name} 입력은 2단계에서 추가합니다. 현재 값만 보여 줍니다.`;
}
/** Fills one 교대 입력 panel: the basic items, the 받침 values and the 형상 values. Each change is checked and applied. */
@@ -651,6 +692,9 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
const shape = $(slot.shape);
const msg = $(slot.msg);
basic.replaceChildren();
+ // ② puts the wing forms in their own card; ① keeps them in the basic items.
+ const wingBox = 'wing' in slot ? $(slot.wing) : basic;
+ wingBox.replaceChildren();
bearings.replaceChildren();
shape.replaceChildren();
// 삽도 (벽체·흉벽 개략 단면): the values it names are the card's values, so the drawing follows each change.
@@ -700,7 +744,7 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
for (const [value, text] of [['general', '일반형'], ['short', '단축형'], ['none', '없음']] as const) sel.appendChild(new Option(text, value));
sel.value = rec.wings?.[side] ?? 'general';
sel.addEventListener('change', () => apply(setWingForm(rec, side, sel.value as WingForm)));
- basic.append(lab, sel);
+ wingBox.append(lab, sel);
}
const restore = el('button', '기본값으로 되돌리기', 'btn small');
restore.type = 'button';
diff --git a/src/style.css b/src/style.css
index 436221b..625c96a 100644
--- a/src/style.css
+++ b/src/style.css
@@ -882,3 +882,10 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
#view-plan.abut-selected #members,
#view-plan.abut-selected section[aria-labelledby="member-title"],
#view-plan.abut-selected #copy-panel { display: none; }
+
+/* ② 부재 · 교대 list: shown only while an abutment is selected (2026-11-03). */
+.abut-members-title,
+#abut-members { display: none; }
+#view-plan.abut-selected .abut-members-title,
+#view-plan.abut-selected #abut-members { display: block; }
+#abut-members .pier-name span:first-child { display: block; }
diff --git a/tests/abut-member-list.test.ts b/tests/abut-member-list.test.ts
new file mode 100644
index 0000000..419998e
--- /dev/null
+++ b/tests/abut-member-list.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import { ABUT_MEMBER_EDITABLE, abutMemberRows } from '../src/core/abut/member-list';
+
+const base = {
+ bearings: [{ seatEl: undefined }, { seatEl: undefined }],
+ groundEl: 33.984,
+};
+
+describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => {
+ it('has the five rows of the mockup, in order', () => {
+ const rows = abutMemberRows(base, '11.253 m');
+ expect(rows.map((r) => r.id)).toEqual(['bearing', 'shape', 'wing', 'slab', 'ground']);
+ expect(rows[0].label).toBe('교량받침·받침블럭·솔플레이트');
+ });
+
+ it('a default abutment shows no change marks', () => {
+ expect(abutMemberRows(base, '—').every((r) => !r.changed)).toBe(true);
+ });
+
+ it('marks the shape when a value differs from the default', () => {
+ const rows = abutMemberRows({ ...base, shape: { parapetHeightMm: 2200 } }, '—');
+ expect(rows.find((r) => r.id === 'shape')?.changed).toBe(true);
+ expect(rows.find((r) => r.id === 'shape')?.summary).toBe('벽체 두께 2400 · 흉벽 2200');
+ });
+
+ it('marks the wing when a side is not the general form, and names both sides', () => {
+ const rows = abutMemberRows({ ...base, wings: { left: 'general', right: 'short' } }, '—');
+ const wing = rows.find((r) => r.id === 'wing');
+ expect(wing?.changed).toBe(true);
+ expect(wing?.summary).toBe('좌 일반형 · 우 단축형');
+ });
+
+ it('marks a 받침 that has its own 교좌면 EL', () => {
+ const rows = abutMemberRows({ ...base, bearings: [{ seatEl: 44.4 }, { seatEl: undefined }] }, '—');
+ expect(rows[0].changed).toBe(true);
+ });
+
+ it('shows the slab width text and the ground EL as given', () => {
+ const rows = abutMemberRows(base, '11.253 m');
+ expect(rows.find((r) => r.id === 'slab')?.summary).toBe('11.253 m');
+ expect(rows.find((r) => r.id === 'ground')?.summary).toBe('지반고 33.984 m');
+ });
+
+ it('only the bearing, shape and wing cards take input in this step', () => {
+ expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, wing: true, slab: false, ground: false });
+ });
+});