From 5b162c24411b4f37fbd6153b40ab06b032dd4826 Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 10:15:03 +0900 Subject: [PATCH] feat(core,view): shape-option rules (plan C5) and pier selection rules (plan C6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C5: each member's options come from one table. Only the defaults (the 1단계 model) are selectable. The others show as disabled chips with 규칙 미정 (Q6). The member summary is built from the defaults. - C6: 전체 선택 acts only on visible, buildable piers; it reads 선택 해제 only when all of those are checked. A check survives a change of the type filter. The pier list scrolls past 300 px, for 20 piers. - Tests: defaults are the only choices; unknown options refused; the 20-pier mixed list with two failed piers; filter and check persistence. - Not verified in a browser. Co-Authored-By: Claude Haiku 5.5 --- index.html | 1 + src/core/selection.ts | 35 ++++++++++++++++++++++ src/core/shape-options.ts | 59 +++++++++++++++++++++++++++++++++++++ src/main.ts | 52 ++++++++++++++++++++++++-------- src/style.css | 8 +++++ tests/selection.test.ts | 49 ++++++++++++++++++++++++++++++ tests/shape-options.test.ts | 37 +++++++++++++++++++++++ 7 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 src/core/selection.ts create mode 100644 src/core/shape-options.ts create mode 100644 tests/selection.test.ts create mode 100644 tests/shape-options.test.ts 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('알 수 없는'); + }); +});