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:
1 parent
f5303e4c2b
commit
0b62f694c5
7 files changed
+239
-9
No files matched your search
+111
-8
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user