feat(core,view): input cards with change tracking (plan B1 · C2)

- 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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-22 09:48:47 +09:00
1 parent f5303e4c2b
commit 0b62f694c5
7 files changed
+239 -9

No files matched your search

+45
View File
@@ -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] };
}
+10 -1
View File
@@ -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;
}
+111 -8
View File
@@ -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();
$<HTMLButtonElement>('regen').disabled = !state.piers.some((p) => p.checked);
const total = pendingTotal();
const regen = $<HTMLButtonElement>('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 = $<HTMLDivElement>('edit-fields');
box.replaceChildren();
const pier = activePier();
const btn = $<HTMLButtonElement>('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 @@ $<HTMLButtonElement>('dlg-close').addEventListener('click', () => $<HTMLDialogEl
$<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('edit-regen').addEventListener('click', () => {
const pier = activePier();
if (pier) regenerateOne(pier.input.name);
});
$<HTMLInputElement>('project-name').addEventListener('input', updateApply);
$<HTMLButtonElement>('pick-file').addEventListener('click', () => $<HTMLInputElement>('file').click());
+8
View File
@@ -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; }