From 0b62f694c5c6326fb7120318ebb33d3005611745 Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 09:48:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(core,view):=20input=20cards=20with=20chang?= =?UTF-8?q?e=20tracking=20(plan=20B1=20=C2=B7=20C2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Three values per pier: defaults (as imported), input (generated), draft (current cards). Overrides = draft vs defaults; pending = draft vs input. - Editable now: 코핑 폭, 코핑 길이, 말뚝 길이. Changing a card does not touch the 3D view until 다시 생성; the header and the card show 변경 n. - Revert puts a field back to its imported default. - Project files store defaults and draft (optional; v1 files still open). - Tests: 코핑 폭 3100 → 3300 gives one override and one pending change; regenerating clears pending but keeps the override; revert; file round trip. - Not verified in a browser (no debuggable Chrome in this session). Co-Authored-By: Claude Haiku 5.5 --- index.html | 6 +++ src/core/changes.ts | 45 ++++++++++++++++ src/core/project.ts | 11 +++- src/main.ts | 119 +++++++++++++++++++++++++++++++++++++++--- src/style.css | 8 +++ tests/changes.test.ts | 42 +++++++++++++++ tests/project.test.ts | 17 ++++++ 7 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 src/core/changes.ts create mode 100644 tests/changes.test.ts diff --git a/index.html b/index.html index 68b98c1..8fc9044 100644 --- a/index.html +++ b/index.html @@ -89,6 +89,12 @@

입력값

+
+

코핑·말뚝 입력 수정

+
+

+ +
diff --git a/src/core/changes.ts b/src/core/changes.ts new file mode 100644 index 0000000..e0a260b --- /dev/null +++ b/src/core/changes.ts @@ -0,0 +1,45 @@ +import type { PierInput } from './types'; + +/** + * Inputs the user can change on the 2단계 cards (spec §5.2 · plan C2). Each one maps to a field of PierInput. + * Extend this list as more cards are enabled; the tracking below works on any of these keys. + */ +export const EDITABLE_FIELDS = [ + { key: 'copingWidthMm', label: '코핑 폭 B', unit: 'mm' }, + { key: 'copingLengthMm', label: '코핑 길이 L', unit: 'mm' }, + { key: 'pileLengthMm', label: '말뚝 길이', unit: 'mm' }, +] as const; + +export type EditableKey = (typeof EDITABLE_FIELDS)[number]['key']; + +/** Keys whose values differ between two inputs. */ +export function changedKeys(a: PierInput, b: PierInput): EditableKey[] { + return EDITABLE_FIELDS.map((f) => f.key).filter((key) => a[key] !== b[key]); +} + +/** + * The three values a pier carries while it is edited (spec §5.5, plan B1): + * - defaults: the values as imported from the sheet (기본값, shown as "기본 3100"). + * - input: the values the 3D model was generated from (생성본). + * - draft: the values the user currently has in the cards (현재 입력). + */ +export interface PierValues { + defaults: PierInput; + input: PierInput; + draft: PierInput; +} + +/** Overrides: draft differs from the imported default (주황 표시, "변경 n건" in the card). */ +export function overrideKeys(p: PierValues): EditableKey[] { + return changedKeys(p.defaults, p.draft); +} + +/** Pending: draft differs from the generated model, so the 3D view is out of date (미반영 변경). */ +export function pendingKeys(p: PierValues): EditableKey[] { + return changedKeys(p.input, p.draft); +} + +/** Puts one field back to its imported default in the draft. */ +export function revertDraft(p: PierValues, key: EditableKey): PierInput { + return { ...p.draft, [key]: p.defaults[key] }; +} diff --git a/src/core/project.ts b/src/core/project.ts index 1fdbe91..b119ff9 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -14,8 +14,12 @@ export interface ProjectInfo { export interface ProjectPier { id: string; - /** Input as read from the spreadsheet. The 3D model is not stored; it is regenerated on open (spec §8). */ + /** Generated input: the values the 3D model was built from. The model is not stored; it is regenerated on open (spec §8). */ input: PierInput; + /** Values as imported from the sheet (기본값). Missing in files saved before change tracking: input is used. */ + defaults?: PierInput; + /** Values currently in the cards (현재 입력). Missing in older files: input is used. */ + draft?: PierInput; } export interface ProjectFile { @@ -54,6 +58,11 @@ export function parseProject(text: string): ProjectFile { if (typeof input.name !== 'string' || !Array.isArray(input.ips) || !Array.isArray(input.bearings)) { throw new Error(`교각 ${i + 1}(${String(input.name)}): 입력 형식이 맞지 않습니다.`); } + for (const extra of [p.defaults, p.draft]) { + if (extra !== undefined && (typeof extra !== 'object' || extra === null || typeof extra.name !== 'string')) { + throw new Error(`교각 ${i + 1}(${input.name}): defaults 또는 draft 형식이 맞지 않습니다.`); + } + } }); return obj as ProjectFile; } diff --git a/src/main.ts b/src/main.ts index 5d97c0a..f2c41b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,14 +6,14 @@ import { parseWorkbook, type SheetReport } from './core/parseWorkbook'; import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; import { buildTemplateWorkbook } from './core/template'; +import { EDITABLE_FIELDS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile } from './core/project'; import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup'; import type { Issue, PierInput, PierType } from './core/types'; import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; /** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */ -interface Pier { - input: PierInput; +interface Pier extends PierValues { model: PierModel | null; issues: Issue[]; checked: boolean; @@ -123,7 +123,12 @@ function buildEntry(input: PierInput, checked: boolean): Pier { issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message }); } } - return { input, model, issues, checked }; + return { input, model, issues, checked, defaults: input, draft: input }; +} + +/** Builds the model from the draft (the cards' values) and keeps the imported defaults. */ +function regenerateEntry(p: Pier): Pier { + return { ...buildEntry(p.draft, p.checked), defaults: p.defaults, draft: p.draft }; } function sheetOk(sheets: SheetReport[], name: string): boolean { @@ -250,7 +255,10 @@ function renderHeader(): void { $('project-line').textContent = parts.join(' · '); $('step1-status').textContent = pier?.model ? '생성됨' : '미생성'; renderSaveStatus(); - $('regen').disabled = !state.piers.some((p) => p.checked); + const total = pendingTotal(); + const regen = $('regen'); + regen.disabled = !state.piers.some((p) => p.checked); + regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성'; } // --- results panel (시안 ① 입력값 · 계산 결과) ---------------------------------------- @@ -350,13 +358,100 @@ function renderAll(): void { renderHeader(); renderPierList(); renderPanels(); + renderEditCard(); scheduleBackup(); } +// --- input cards and change tracking (plan B1 · C2) ------------------------------------- +function pendingTotal(): number { + return state.piers.filter((p) => p.checked).reduce((n, p) => n + pendingKeys(p).length, 0); +} + +function fieldLabel(key: EditableKey): string { + return EDITABLE_FIELDS.find((f) => f.key === key)!.label; +} + +function editField(name: string, key: EditableKey, raw: string): void { + const p = state.piers.find((q) => q.input.name === name); + if (!p) return; + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) { + setStatus(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`); + renderAll(); + return; + } + p.draft = { ...p.draft, [key]: value }; + renderAll(); +} + +function revertField(name: string, key: EditableKey): void { + const p = state.piers.find((q) => q.input.name === name); + if (!p) return; + p.draft = revertDraft(p, key); + renderAll(); +} + +function regenerateOne(name: string): void { + const i = state.piers.findIndex((q) => q.input.name === name); + if (i < 0) return; + state.piers[i] = regenerateEntry(state.piers[i]); + setupScene(); + renderAll(); + const msg = state.piers[i].model ? '형상을 다시 생성했습니다.' : '형상을 생성하지 못했습니다. 알림을 확인하세요.'; + setStatus(msg); +} + +function renderEditCard(): void { + const box = $('edit-fields'); + box.replaceChildren(); + const pier = activePier(); + const btn = $('edit-regen'); + if (!pier) { + $('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.'; + btn.textContent = '다시 생성 · 변경 0'; + btn.disabled = true; + return; + } + const overrides = overrideKeys(pier); + const pending = pendingKeys(pier); + for (const f of EDITABLE_FIELDS) { + const changed = overrides.includes(f.key); + const row = el('div', undefined, changed ? 'edit-row changed' : 'edit-row'); + const id = `edit-${f.key}`; + const label = el('label', `${f.label} (${f.unit})`); + label.htmlFor = id; + const input = el('input'); + input.id = id; + input.type = 'number'; + input.step = 'any'; + input.value = String(pier.draft[f.key]); + input.addEventListener('change', () => editField(pier.input.name, f.key, input.value)); + row.append(label, input, el('span', `기본 ${pier.defaults[f.key]}`, 'muted small')); + if (changed) { + const revert = el('button', '되돌리기', 'btn small'); + revert.type = 'button'; + revert.addEventListener('click', () => revertField(pier.input.name, f.key)); + row.appendChild(revert); + } + box.appendChild(row); + } + $('edit-summary').textContent = pending.length + ? `미반영 변경 ${pending.length}건. 3D 그림은 생성본을 유지합니다.` + : overrides.length + ? `기본값과 ${overrides.length}건 다릅니다. 생성본이 최신입니다.` + : '기본값과 같습니다.'; + btn.textContent = `다시 생성 · 변경 ${pending.length}`; + btn.disabled = pending.length === 0; +} + // --- project file (plan B3·B4·B5) -------------------------------------------------------- /** Everything that a save writes except the history, so that a save itself does not look like a change. */ function snapshot(): string { - return JSON.stringify({ project: state.project, source: state.source, piers: state.piers.map((p) => p.input) }); + return JSON.stringify({ + project: state.project, + source: state.source, + piers: state.piers.map((p) => ({ input: p.input, defaults: p.defaults, draft: p.draft })), + }); } function isDirty(): boolean { @@ -368,7 +463,7 @@ function currentProjectText(): string { schemaVersion: SCHEMA_VERSION, project: { name: state.project.name, bridge: state.project.bridge, client: '', author: '', crs: '', memo: '' }, source: state.source, - piers: state.piers.map((p) => ({ id: p.input.name, input: p.input })), + piers: state.piers.map((p) => ({ id: p.input.name, input: p.input, defaults: p.defaults, draft: p.draft })), history: state.history, }); } @@ -409,7 +504,11 @@ function openProjectText(text: string, saved: boolean): void { state.project = { name: parsed.project.name, bridge: parsed.project.bridge }; state.source = parsed.source; state.history = parsed.history; - state.piers = parsed.piers.map((p) => buildEntry(p.input, false)); + state.piers = parsed.piers.map((p) => ({ + ...buildEntry(p.input, false), + defaults: p.defaults ?? p.input, + draft: p.draft ?? p.input, + })); state.active = state.piers[0]?.input.name ?? null; state.filter = 'all'; state.pending = null; @@ -613,7 +712,7 @@ function regenerateChecked(): void { state.piers = state.piers.map((p) => { if (!p.checked) return p; count++; - const next = buildEntry(p.input, true); + const next = regenerateEntry(p); if (!next.model) errors++; return next; }); @@ -628,6 +727,10 @@ $('dlg-close').addEventListener('click', () => $('dlg-cancel').addEventListener('click', () => $('import-dialog').close()); $('dlg-apply').addEventListener('click', applyImport); $('regen').addEventListener('click', regenerateChecked); +$('edit-regen').addEventListener('click', () => { + const pier = activePier(); + if (pier) regenerateOne(pier.input.name); +}); $('project-name').addEventListener('input', updateApply); $('pick-file').addEventListener('click', () => $('file').click()); diff --git a/src/style.css b/src/style.css index fde2c94..61902a3 100644 --- a/src/style.css +++ b/src/style.css @@ -587,3 +587,11 @@ table { height: 60vh; } } + +/* Input cards (2단계 C2): changed values are outlined in orange, with the imported default beside them. */ +.edit-card { margin-top: 12px; border-top: 1px solid #d5dde6; padding-top: 10px; } +.edit-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; } +.edit-row label { min-width: 150px; } +.edit-row input { width: 110px; padding: 4px 6px; border: 1px solid #b9c4d0; border-radius: 4px; } +.edit-row.changed input { border: 2px solid #e8833a; background: #fff6ee; } +.edit-row.changed label { color: #a4530e; } diff --git a/tests/changes.test.ts b/tests/changes.test.ts new file mode 100644 index 0000000..dd8593d --- /dev/null +++ b/tests/changes.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { overrideKeys, pendingKeys, revertDraft, type PierValues } from '../src/core/changes'; +import { loadP1 } from './helpers'; + +function fresh(): PierValues { + const input = loadP1(); + return { defaults: input, input, draft: input }; +} + +describe('input change tracking (plan B1 · C2)', () => { + it('a new pier has no overrides and nothing pending', () => { + const p = fresh(); + expect(overrideKeys(p)).toEqual([]); + expect(pendingKeys(p)).toEqual([]); + }); + + it('coping width 3100 → 3300: one override and one pending change', () => { + const p = fresh(); + const next = { ...p, draft: { ...p.draft, copingWidthMm: 3300 } }; + expect(overrideKeys(next)).toEqual(['copingWidthMm']); + expect(pendingKeys(next)).toEqual(['copingWidthMm']); + }); + + it('regenerating clears the pending change but keeps the override', () => { + const p = fresh(); + const edited = { ...p, draft: { ...p.draft, copingWidthMm: 3300 } }; + const regenerated: PierValues = { ...edited, input: edited.draft }; + expect(pendingKeys(regenerated)).toEqual([]); + expect(overrideKeys(regenerated)).toEqual(['copingWidthMm']); + expect(buildPier(regenerated.input).copingB).toBeCloseTo(3.3, 9); + }); + + it('reverting to the default removes the override', () => { + const p = fresh(); + const edited = { ...p, draft: { ...p.draft, copingWidthMm: 3300 } }; + const reverted = { ...edited, draft: revertDraft(edited, 'copingWidthMm') }; + expect(overrideKeys(reverted)).toEqual([]); + // The draft is back to the generated value, so nothing is pending either. + expect(pendingKeys(reverted)).toEqual([]); + }); +}); diff --git a/tests/project.test.ts b/tests/project.test.ts index 3715474..ea9ea83 100644 --- a/tests/project.test.ts +++ b/tests/project.test.ts @@ -46,4 +46,21 @@ describe('project file round trip (spec §8, plan B3)', () => { expect(() => parseProject(JSON.stringify(broken))).toThrow('교각 2'); expect(() => parseProject('{ not json')).toThrow('JSON'); }); + + it('keeps defaults and an unregenerated draft through save and open', () => { + const original = sampleProject(); + const p1 = original.piers[0]; + original.piers[0] = { ...p1, defaults: p1.input, draft: { ...p1.input, copingWidthMm: 3300 } }; + const reopened = parseProject(serializeProject(original)); + expect(reopened.piers[0].draft?.copingWidthMm).toBe(3300); + expect(reopened.piers[0].input.copingWidthMm).toBe(3100); + }); + + it('a file saved before change tracking (no defaults or draft) still opens', () => { + const legacy = sampleProject(); + const text = JSON.stringify({ ...legacy, piers: legacy.piers.map((p) => ({ id: p.id, input: p.input })) }); + const reopened = parseProject(text); + expect(reopened.piers[0].defaults).toBeUndefined(); + expect(reopened.piers[0].draft).toBeUndefined(); + }); });