From fae8d7b9b8326919b83556ab34de5f116921d8df Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 09:57:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(view):=20reflect=20=EC=8B=9C=EC=95=88=20?= =?UTF-8?q?=E2=91=A0=20main=20screen=20and=20=E2=91=A4=20save=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header follows the mockup: dark bar, project line, stage chips (② shows the pending change count), save status, 저장 / 엑셀 불러오기 / 프로젝트 열기 and the primary 선택 교각 다시 생성 · 변경 n button. - Left column: pier list with header row, 선택 n기 bulk bar, 입력값 and the 2단계 input card. The bottom input panel is gone. - First save opens 시안 ⑤ (project name, bridge, client, author, 좌표계, memo); later Ctrl+S / 저장 write the same file, as the spec's B5 requires. - Project info (client, author, 좌표계, memo) is kept in the project file. - Style layer uses the mockup's tokens (#18212B header, #1F5FA8 primary, #D5DBE2 borders, #E3EDF8 selected row, JetBrains Mono for numbers). - Not reflected: ② and ③ screens (not built yet), and the mockup's static front preview (the app draws the model in 3D). Not verified in a browser. Co-Authored-By: Claude Haiku 5.5 --- index.html | 99 ++++++++++++++++++++++++++++++++++++++++----------- src/main.ts | 71 ++++++++++++++++++++++++++++++++---- src/style.css | 50 ++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 27 deletions(-) diff --git a/index.html b/index.html index 8fc9044..4da964f 100644 --- a/index.html +++ b/index.html @@ -9,21 +9,22 @@
- 교각 설계 - 프로젝트 미지정 + Pierzainer + 교각 설계 + 프로젝트 미지정
- - - - + 저장 전 + + + + - 저장 전
@@ -36,15 +37,34 @@
@@ -86,16 +106,6 @@
-
-

입력값

-
-
-

코핑·말뚝 입력 수정

-
-

- -
-
@@ -187,6 +197,55 @@ + +
+

프로젝트 정보 · 저장

+ +
+
+
+
+

프로젝트 정보

+ + + + + + +
+
+
+
+

프로젝트 파일로 저장

+

파일 이름

+

파일에 담기는 내용

+
    +
  • 프로젝트 정보와 엑셀 입력값 (교각·IP·받침)
  • +
  • 교각별 기본값과 현재 입력 (기본값 대비 변경 내역)
  • +
  • 3D 형상은 담지 않고, 파일을 열 때 입력값으로 다시 생성합니다
  • +
+

처음 저장할 때만 이 창이 열립니다. 이후에는 헤더의 저장(Ctrl+S)이 같은 파일에 바로 저장합니다.

+
+
+
+
+ + +
+
+ diff --git a/src/main.ts b/src/main.ts index f2c41b0..7765eaf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,7 @@ 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 { 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'; import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; @@ -38,6 +38,7 @@ const VIEWS: { kind: ViewKind; label: string }[] = [ ]; const TYPES: PierType[] = ['T형', '2주형', '3주형']; +const CRS = '국토좌표계 (x = 동, y = 북)'; interface Pending { fileName: string; @@ -52,7 +53,9 @@ const state = { view: 'iso' as ViewKind, visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.parts).map((p) => [p, true])) as Record, scene: null as PierScene | null, - project: { name: '', bridge: '' }, + project: { name: '', bridge: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo, + /** True once the project has a file on disk (first save via 시안 ⑤, or an opened file). Later saves go straight to it. */ + savedOnce: false, filter: 'all' as 'all' | PierType, pending: null as Pending | null, source: { fileName: '', sheets: [] as string[] }, @@ -256,9 +259,13 @@ function renderHeader(): void { $('step1-status').textContent = pier?.model ? '생성됨' : '미생성'; renderSaveStatus(); const total = pendingTotal(); + const checked = state.piers.filter((p) => p.checked).length; + $('step2-status').textContent = total ? `변경 ${total}건 · 재생성 필요` : '예정'; const regen = $('regen'); - regen.disabled = !state.piers.some((p) => p.checked); + regen.disabled = checked === 0; regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성'; + $('bulk-count').textContent = `선택 ${checked}기`; + $('bulk-regen').disabled = checked === 0; } // --- results panel (시안 ① 입력값 · 계산 결과) ---------------------------------------- @@ -461,7 +468,7 @@ function isDirty(): boolean { function currentProjectText(): string { return serializeProject({ schemaVersion: SCHEMA_VERSION, - project: { name: state.project.name, bridge: state.project.bridge, client: '', author: '', crs: '', memo: '' }, + project: { ...state.project }, source: state.source, piers: state.piers.map((p) => ({ id: p.input.name, input: p.input, defaults: p.defaults, draft: p.draft })), history: state.history, @@ -481,11 +488,55 @@ function renderSaveStatus(): void { } } +/** 저장 button and Ctrl+S: the first save opens the project dialog (시안 ⑤); later saves write the same file. */ function saveProject(): void { if (state.piers.length === 0) return; + if (!state.savedOnce) { + openSaveDialog(); + return; + } + writeProjectFile(); +} + +function projectFileName(): string { + return `${state.project.bridge || state.project.name || '교각설계'}_교각설계.pzr.json`; +} + +function openSaveDialog(): void { + $('sv-name').value = state.project.name; + $('sv-bridge').value = state.project.bridge; + $('sv-client').value = state.project.client; + $('sv-author').value = state.project.author; + $('sv-memo').value = state.project.memo; + $('sv-file').textContent = projectFileName(); + $('save-dialog').showModal(); + $('sv-name').focus(); +} + +function confirmSaveDialog(): void { + const name = $('sv-name').value.trim(); + if (!name) { + $('sv-name').focus(); + setStatus('프로젝트명을 입력하세요.'); + return; + } + state.project = { + name, + bridge: $('sv-bridge').value.trim(), + client: $('sv-client').value.trim(), + author: $('sv-author').value.trim(), + crs: CRS, + memo: $('sv-memo').value.trim(), + }; + $('save-dialog').close(); + writeProjectFile(); +} + +function writeProjectFile(): void { + state.savedOnce = true; state.history = [...state.history, { version: String(state.history.length + 1), note: '저장', at: new Date().toISOString() }]; const text = currentProjectText(); - const name = `${state.project.bridge || state.project.name || '교각설계'}_교각설계.pzr.json`; + const name = projectFileName(); const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; @@ -501,7 +552,7 @@ function saveProject(): void { /** Opens a project from its text. A backup stays unsaved until the user saves it. */ function openProjectText(text: string, saved: boolean): void { const parsed = parseProject(text); - state.project = { name: parsed.project.name, bridge: parsed.project.bridge }; + state.project = { ...parsed.project }; state.source = parsed.source; state.history = parsed.history; state.piers = parsed.piers.map((p) => ({ @@ -520,6 +571,7 @@ function openProjectText(text: string, saved: boolean): void { async function openProjectFile(file: File): Promise { try { openProjectText(await file.text(), true); + state.savedOnce = true; clearBackup().catch(() => undefined); setStatus(`${file.name}을 열었습니다. ${state.piers.length}기를 원본 엑셀 없이 다시 생성했습니다.`); } catch (err) { @@ -680,7 +732,7 @@ function applyImport(): void { const p = state.pending; const name = $('project-name').value.trim(); if (!p || !name) return; - state.project = { name, bridge: $('bridge-name').value.trim() }; + state.project = { ...state.project, name, bridge: $('bridge-name').value.trim() }; state.piers = p.entries.filter((e) => e.checked && selectable(p, e)); state.active = state.piers[0]?.input.name ?? null; state.filter = 'all'; @@ -688,6 +740,7 @@ function applyImport(): void { state.source = { fileName: p.fileName, sheets: p.sheets.map((sheet) => sheet.name) }; state.history = []; state.savedSnapshot = null; + state.savedOnce = false; $('import-dialog').close(); setupScene(); renderAll(); @@ -727,6 +780,10 @@ $('dlg-close').addEventListener('click', () => $('dlg-cancel').addEventListener('click', () => $('import-dialog').close()); $('dlg-apply').addEventListener('click', applyImport); $('regen').addEventListener('click', regenerateChecked); +$('bulk-regen').addEventListener('click', regenerateChecked); +$('sv-save').addEventListener('click', confirmSaveDialog); +$('sv-cancel').addEventListener('click', () => $('save-dialog').close()); +$('sv-close').addEventListener('click', () => $('save-dialog').close()); $('edit-regen').addEventListener('click', () => { const pier = activePier(); if (pier) regenerateOne(pier.input.name); diff --git a/src/style.css b/src/style.css index 61902a3..ada7491 100644 --- a/src/style.css +++ b/src/style.css @@ -595,3 +595,53 @@ table { .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; } + +/* ---- 시안 ① 디자인 반영 (RAG/Pierzainer UI 시안 (1).html) ---- */ +body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; } +.top { background: #18212b; color: #ffffff; padding: 10px 24px; } +.top .brand strong { font-size: 18px; letter-spacing: 0.01em; } +.top .brand > span:not(.proj) { font-size: 12px; color: #b8c2ce; } +.proj { padding-left: 14px; border-left: 1px solid #33404f; color: #ffffff; font-size: 13px; font-weight: 700; } +.steps .step { color: #dce3ea; min-height: 44px; padding: 4px 14px; border-radius: 6px; background: transparent; border: 0; } +.steps .step.current { background: #ffffff; color: #18212b; } +.steps .step em { font-style: normal; color: #b8c2ce; font-size: 12px; } +.steps .step.current em { color: #55606e; } +.steps .step[aria-disabled='true'] em { color: #b8c2ce; } +.savest { color: #f2c48d; font-size: 12px; } +.actions { color: #ffffff; } +.btn-s, .btn-p, .btn-o { display: inline-flex; align-items: center; font-family: inherit; font-size: 13px; min-height: 44px; border-radius: 6px; cursor: pointer; box-sizing: border-box; text-decoration: none; } +.btn-s { padding: 0 14px; border: 1px solid #8c99a8; background: transparent; color: #ffffff; } +.btn-s:hover { border-color: #c3cbd4; } +.btn-s:disabled { opacity: 0.45; cursor: default; } +.btn-p { padding: 0 16px; border: 0; background: #1f5fa8; color: #ffffff; font-weight: 700; font-size: 14px; } +.btn-p:hover:not(:disabled) { background: #174c88; } +.btn-p:disabled { opacity: 0.45; cursor: default; } +.btn-o { padding: 0 12px; min-height: 40px; border: 1px solid #1f5fa8; background: #ffffff; color: #1f5fa8; } +.btn-o:hover:not(:disabled) { background: #f2f7fc; color: #123e70; } +.btn-o:disabled { opacity: 0.45; cursor: default; } +.lnk { font-size: 12px; color: #1f5fa8; background: none; border: 0; min-height: 36px; padding: 0 4px; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; } +.lbl { font-size: 12px; font-weight: 700; letter-spacing: 0.02em; color: #55606e; margin: 0 0 8px; } +.panel { background: #ffffff; border: 1px solid #d5dbe2; border-radius: 8px; padding: 16px; } +.piers { display: flex; flex-direction: column; gap: 6px; } +.pier-tools { display: flex; align-items: center; gap: 8px; padding: 0 4px 8px; } +.pier-tools select { border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; min-height: 36px; } +.phead { display: grid; grid-template-columns: 36px 1fr auto; gap: 8px; padding: 0 10px 6px; font-size: 12px; color: #55606e; border-bottom: 1px solid #d5dbe2; } +.pier-items li { display: grid; grid-template-columns: 36px 1fr auto; align-items: center; gap: 8px; border-radius: 6px; padding-right: 6px; } +.pier-items li:has(.pier-name[aria-pressed='true']) { background: #e3edf8; } +.pier-name { border: 0; background: transparent; color: #18212b; text-align: left; font-size: 13px; min-height: 44px; padding: 0 6px; } +.pier-name[aria-pressed='true'] { color: #123e70; font-weight: 700; } +.badge { font-size: 12px; border-radius: 10px; padding: 2px 8px; background: #e2f2e7; color: #1d6b3a; white-space: nowrap; } +.badge.error { background: #fde2e2; color: #9b2c2c; } +.pnote { font-size: 12px; color: #55606e; margin: 8px 4px 0; line-height: 1.6; } +.bulk { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; padding: 8px 10px; border-radius: 6px; background: #f2f7fc; font-size: 13px; color: #123e70; } +.bulk .grow { flex: 1 1 auto; } +.pier-input { margin-top: 14px; border-top: 1px solid #d5dbe2; padding-top: 12px; } +.kv div { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid #e6eaef; font-size: 13px; } +.kv dt { color: #55606e; } +.kv dd { margin: 0; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; text-align: right; } +.views { display: flex; gap: 2px; background: #eef1f4; border-radius: 6px; padding: 2px; } +.views .btn { min-height: 40px; padding: 0 14px; border: 0; border-radius: 4px; background: transparent; color: #18212b; } +.views .btn[aria-pressed='true'] { background: #1f5fa8; color: #ffffff; font-weight: 700; } +.viewer-head { gap: 8px; } +.edit-card h3 { font-size: 12px; color: #55606e; } +