diff --git a/index.html b/index.html
index 9e42e2b..e0676c1 100644
--- a/index.html
+++ b/index.html
@@ -120,6 +120,7 @@
코핑
+
diff --git a/src/core/selection.ts b/src/core/selection.ts
new file mode 100644
index 0000000..0f79182
--- /dev/null
+++ b/src/core/selection.ts
@@ -0,0 +1,35 @@
+import type { PierType } from './types';
+
+/** Pier list selection (spec §9 · plan C6): type filter, select-all over the visible piers, and checks that survive filtering. */
+export type TypeFilter = 'all' | PierType;
+
+export interface Selectable {
+ input: { type: PierType };
+ /** null when the pier failed to build: such a pier cannot be checked. */
+ model: unknown | null;
+ checked: boolean;
+}
+
+export function isVisible(pier: T, filter: TypeFilter): boolean {
+ return filter === 'all' || pier.input.type === filter;
+}
+
+/** Piers the 전체 선택 button acts on: visible under the filter and buildable. */
+export function selectableVisible(piers: T[], filter: TypeFilter): T[] {
+ return piers.filter((p) => isVisible(p, filter) && p.model !== null);
+}
+
+/** True when every selectable visible pier is checked (then the button reads 선택 해제). */
+export function allVisibleChecked(piers: T[], filter: TypeFilter): boolean {
+ const targets = selectableVisible(piers, filter);
+ return targets.length > 0 && targets.every((p) => p.checked);
+}
+
+/** Sets the check of every selectable visible pier. Hidden piers keep their check. */
+export function setVisibleChecked(piers: T[], filter: TypeFilter, checked: boolean): T[] {
+ return piers.map((p) => (isVisible(p, filter) && p.model !== null ? { ...p, checked } : p));
+}
+
+export function checkedCount(piers: T[]): number {
+ return piers.filter((p) => p.checked).length;
+}
diff --git a/src/core/shape-options.ts b/src/core/shape-options.ts
new file mode 100644
index 0000000..977dc28
--- /dev/null
+++ b/src/core/shape-options.ts
@@ -0,0 +1,59 @@
+/**
+ * 형상 선택지 (spec §5.2 · plan C5). Only the default of each group can be chosen until the rules for the other
+ * options are confirmed (Q6). The other options are shown but cannot be selected.
+ */
+export type MemberId = 'bearing' | 'coping' | 'column' | 'footing' | 'ground';
+
+export interface ShapeOption {
+ label: string;
+ /** 굵은 글씨 in the spec: the default that the 1단계 model builds. */
+ isDefault: boolean;
+}
+
+export interface ShapeGroup {
+ title: string;
+ options: ShapeOption[];
+}
+
+const d = (label: string): ShapeOption => ({ label, isDefault: true });
+const x = (label: string): ShapeOption => ({ label, isDefault: false });
+
+export const SHAPE_GROUPS: Record = {
+ bearing: [{ title: '배치', options: [d('분리'), x('일체')] }],
+ coping: [
+ { title: '교좌면', options: [d('직각'), x('모따기'), x('라운드')] },
+ { title: '하면', options: [d('직선'), x('곡선 오목'), x('곡선 볼록')] },
+ ],
+ column: [
+ { title: '단면', options: [d('원형'), x('트랙'), x('사각')] },
+ { title: '중공', options: [d('중실'), x('중공')] },
+ ],
+ footing: [
+ { title: '형상', options: [d('직사각'), x('경사')] },
+ { title: '모따기', options: [d('없음'), x('있음')] },
+ { title: '말뚝 종류', options: [d('강관'), x('PHC'), x('복합')] },
+ { title: '경사말뚝', options: [d('없음'), x('있음')] },
+ ],
+ ground: [{ title: '위치', options: [d('육상'), x('하상'), x('해상')] }],
+};
+
+export const UNDEFINED_REASON = '규칙 미정 (Q6 확정 전 선택 불가)';
+
+/** Options currently selectable. Defaults are the only choices today. */
+export function isSelectable(option: ShapeOption): boolean {
+ return option.isDefault;
+}
+
+/** The default summary of a member, e.g. "교좌면 직각 · 하면 직선". */
+export function defaultSummary(member: MemberId): string {
+ return SHAPE_GROUPS[member].map((g) => g.options.find((o) => o.isDefault)!.label).join(' · ');
+}
+
+/** Chooses an option in a group. Refuses options whose rules are not confirmed. */
+export function choose(member: MemberId, groupIndex: number, optionLabel: string): string {
+ const group = SHAPE_GROUPS[member][groupIndex];
+ const option = group?.options.find((o) => o.label === optionLabel);
+ if (!option) throw new Error(`알 수 없는 선택지: ${optionLabel}`);
+ if (!isSelectable(option)) throw new Error(`${optionLabel}: ${UNDEFINED_REASON}`);
+ return option.label;
+}
diff --git a/src/main.ts b/src/main.ts
index 66a7f69..a6a79f4 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -8,6 +8,8 @@ import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/valid
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 { allVisibleChecked, setVisibleChecked, 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';
import type { Issue, PierInput, PierType } from './core/types';
@@ -42,13 +44,12 @@ 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: '지반고는 입력값을 표시만 합니다. 지층 입력은 다음 단계에서 추가합니다.' },
+const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string }[] = [
+ { id: 'bearing', label: '교량받침·받침블럭·솔플레이트', keys: [], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' },
+ { id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm'], note: '폭과 길이를 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' },
+ { id: 'column', label: '기둥', keys: [], note: '기둥 지름 입력은 다음 단계에서 추가합니다. 높이와 위치는 계산값입니다.' },
+ { id: 'footing', label: '확대기초 · 말뚝', keys: ['pileLengthMm'], note: '말뚝 길이를 입력합니다. 열별 본수는 다음 단계에서 추가합니다.' },
+ { id: 'ground', label: '지반·지층', keys: [], note: '지반고는 입력값을 표시만 합니다. 지층 입력은 다음 단계에서 추가합니다.' },
];
interface Pending {
@@ -236,8 +237,7 @@ function renderPierList(): void {
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$('pier-empty').hidden = state.piers.length > 0;
- const allChecked = visible.length > 0 && visible.every((p) => p.checked);
- $('select-all').textContent = allChecked ? '선택 해제' : '전체 선택';
+ $('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. */
@@ -590,6 +590,32 @@ function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { over
return { overrides, pending };
}
+/** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */
+function renderShapeOptions(member: MemberId): void {
+ const box = $('member-options');
+ box.replaceChildren();
+ SHAPE_GROUPS[member].forEach((group, gi) => {
+ const row = el('div', undefined, 'shape-row');
+ row.appendChild(el('span', group.title, 'lbl'));
+ for (const option of group.options) {
+ const chip = el('button', option.label, 'chip-opt');
+ chip.type = 'button';
+ chip.setAttribute('aria-pressed', String(option.isDefault));
+ chip.disabled = !isSelectable(option);
+ if (chip.disabled) chip.title = UNDEFINED_REASON;
+ chip.addEventListener('click', () => {
+ try {
+ choose(member, gi, option.label);
+ } catch (err) {
+ setStatus((err as Error).message);
+ }
+ });
+ row.appendChild(chip);
+ }
+ box.appendChild(row);
+ });
+}
+
// --- ② 형상계획 (plan.md C1) -------------------------------------------------------------
function setStage(stage: 'shape' | 'plan'): void {
state.stage = stage;
@@ -621,7 +647,9 @@ function renderPlan(): void {
const member = MEMBERS.find((m) => m.id === state.member)!;
$('member-title').textContent = member.label;
- $('member-summary').textContent = `기본: ${member.summary} · 선택: ${member.summary}`;
+ // Default and current selection are shown separately (spec §5.1). Only defaults are selectable today.
+ $('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${defaultSummary(member.id)}`;
+ renderShapeOptions(member.id);
$('member-note').textContent = member.note;
const regen = $('member-regen');
const fields = $('member-fields');
@@ -1056,9 +1084,7 @@ $('type-filter').addEventListener('change', (ev) => {
renderPierList();
});
$('select-all').addEventListener('click', () => {
- const visible = state.piers.filter((p) => (state.filter === 'all' || p.input.type === state.filter) && p.model);
- const all = visible.every((p) => p.checked);
- for (const p of visible) p.checked = !all;
+ state.piers = setVisibleChecked(state.piers, state.filter, !allVisibleChecked(state.piers, state.filter));
renderAll();
});
diff --git a/src/style.css b/src/style.css
index 367757f..3cc8386 100644
--- a/src/style.css
+++ b/src/style.css
@@ -663,4 +663,12 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
#member-summary { color: #55606e; margin: 4px 0 8px; }
.plan .viewer { flex: 1 1 380px; }
.plan .results { flex: 1 1 300px; max-width: 360px; }
+/* 형상 선택칩 (C5) and the scrolling pier list (C6) */
+.shape-options { display: grid; gap: 6px; margin: 6px 0 10px; }
+.shape-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.shape-row .lbl { min-width: 84px; margin: 0; }
+.chip-opt { min-height: 36px; padding: 0 10px; border: 1px solid #c3cbd4; border-radius: 16px; background: #ffffff; color: #18212b; font-size: 13px; cursor: pointer; }
+.chip-opt[aria-pressed='true'] { background: #1f5fa8; border-color: #1f5fa8; color: #ffffff; font-weight: 700; }
+.chip-opt:disabled { color: #8a95a3; background: #f2f4f7; border-style: dashed; cursor: default; }
+.pier-items { max-height: 300px; overflow-y: auto; }
diff --git a/tests/selection.test.ts b/tests/selection.test.ts
new file mode 100644
index 0000000..04e9e94
--- /dev/null
+++ b/tests/selection.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { allVisibleChecked, checkedCount, selectableVisible, setVisibleChecked, type Selectable } from '../src/core/selection';
+import type { PierType } from '../src/core/types';
+
+const TYPES: PierType[] = ['T형', '2주형', '3주형'];
+
+/** 20 piers: mixed types, and two that failed to build (model null). */
+function twenty(): (Selectable & { name: string })[] {
+ return Array.from({ length: 20 }, (_, i) => ({
+ name: `P${i + 1}`,
+ input: { type: TYPES[i % 3] },
+ model: i === 4 || i === 13 ? null : {},
+ checked: false,
+ }));
+}
+
+describe('교각 목록 다중 선택 (spec §9, plan C6)', () => {
+ it('전체 선택 acts on visible, buildable piers only', () => {
+ const next = setVisibleChecked(twenty(), 'all', true);
+ expect(checkedCount(next)).toBe(18);
+ expect(next.find((p) => p.name === 'P5')!.checked).toBe(false);
+ });
+
+ it('the type filter limits 전체 선택 to that type', () => {
+ const next = setVisibleChecked(twenty(), 'T형', true);
+ const t = next.filter((p) => p.input.type === 'T형' && p.model !== null);
+ expect(t.every((p) => p.checked)).toBe(true);
+ expect(checkedCount(next)).toBe(t.length);
+ });
+
+ it('checks made under one filter survive a change of filter', () => {
+ let piers = setVisibleChecked(twenty(), '2주형', true);
+ const before = checkedCount(piers);
+ piers = setVisibleChecked(piers, 'T형', false);
+ expect(checkedCount(piers)).toBe(before);
+ });
+
+ it('the button reads 선택 해제 only when all selectable visible piers are checked', () => {
+ let piers = setVisibleChecked(twenty(), '3주형', true);
+ expect(allVisibleChecked(piers, '3주형')).toBe(true);
+ expect(allVisibleChecked(piers, 'all')).toBe(false);
+ piers = setVisibleChecked(piers, '3주형', false);
+ expect(allVisibleChecked(piers, '3주형')).toBe(false);
+ });
+
+ it('a list of 20 piers keeps every selectable one', () => {
+ expect(selectableVisible(twenty(), 'all')).toHaveLength(18);
+ });
+});
diff --git a/tests/shape-options.test.ts b/tests/shape-options.test.ts
new file mode 100644
index 0000000..3a24d68
--- /dev/null
+++ b/tests/shape-options.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, type MemberId } from '../src/core/shape-options';
+
+const MEMBERS: MemberId[] = ['bearing', 'coping', 'column', 'footing', 'ground'];
+
+describe('형상 선택지 (spec §5.2, plan C5)', () => {
+ it('every group has exactly one default, and it is the 1단계 model', () => {
+ for (const m of MEMBERS) {
+ for (const g of SHAPE_GROUPS[m]) {
+ expect(g.options.filter((o) => o.isDefault)).toHaveLength(1);
+ }
+ }
+ expect(defaultSummary('coping')).toBe('직각 · 직선');
+ expect(defaultSummary('column')).toBe('원형 · 중실');
+ expect(defaultSummary('footing')).toBe('직사각 · 없음 · 강관 · 없음');
+ expect(defaultSummary('ground')).toBe('육상');
+ });
+
+ it('only defaults are selectable; every other option is refused with the reason', () => {
+ for (const m of MEMBERS) {
+ SHAPE_GROUPS[m].forEach((g, gi) => {
+ for (const o of g.options) {
+ if (o.isDefault) {
+ expect(choose(m, gi, o.label)).toBe(o.label);
+ } else {
+ expect(isSelectable(o)).toBe(false);
+ expect(() => choose(m, gi, o.label)).toThrow('규칙 미정');
+ }
+ }
+ });
+ }
+ });
+
+ it('an unknown option is refused', () => {
+ expect(() => choose('coping', 0, '없는선택')).toThrow('알 수 없는');
+ });
+});