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('알 수 없는'); + }); +});