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
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user