import './style.css'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import * as XLSX from 'xlsx'; 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, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes'; import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles'; import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy'; import { pileGeometry } from './core/geometry'; import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; 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'; /** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */ interface Pier extends PierValues { model: PierModel | null; issues: Issue[]; checked: boolean; } /** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */ const PART_GROUPS: { label: string; parts: PartName[] }[] = [ { label: '받침·받침블럭', parts: ['blocks', 'pads'] }, { label: '코핑', parts: ['coping'] }, { label: '기둥', parts: ['columns'] }, { label: '확대기초', parts: ['footings'] }, { label: '말뚝', parts: ['piles'] }, { label: '버림', parts: ['blinding'] }, { label: '지반면', parts: ['ground'] }, ]; const VIEWS: { kind: ViewKind; label: string }[] = [ { kind: 'iso', label: '아이소' }, { kind: 'front', label: '정면' }, { kind: 'side', label: '측면' }, { kind: 'top', label: '평면' }, ]; const TYPES: PierType[] = ['T형', '2주형', '3주형']; const CRS = '국토좌표계 (x = 동, y = 북)'; /** 시안 ② 부재 목록 (spec §5.2). `keys` are the inputs that exist today; the rest show values only. */ const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string }[] = [ { id: 'bearing', label: '교량받침·받침블럭·솔플레이트', keys: [], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' }, { id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm'], note: '폭과 길이를 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' }, { id: 'column', label: '기둥', keys: [], note: '기둥 지름 입력은 다음 단계에서 추가합니다. 높이와 위치는 계산값입니다.' }, { id: 'footing', label: '확대기초 · 말뚝', keys: ['pileLengthMm'], note: '말뚝 길이를 입력합니다. 열별 본수는 다음 단계에서 추가합니다.' }, { id: 'ground', label: '지반·지층', keys: [], note: '지반고는 입력값을 표시만 합니다. 지층 입력은 다음 단계에서 추가합니다.' }, ]; interface Pending { fileName: string; entries: Pier[]; sheets: SheetReport[]; preview: string | null; } const state = { piers: [] as Pier[], active: null as string | null, 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: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo, /** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */ stage: 'shape' as 'shape' | 'plan', /** Member card selected on ② (시안 ② 부재 목록). */ member: 'coping' as MemberId, /** 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[] }, history: [] as ProjectFile['history'], /** Snapshot of the inputs at the last save or open. null until the project is first saved. */ savedSnapshot: null as string | null, }; let pendingBackup: Backup | null = null; let backupTimer: number | undefined; const $ = (id: string): T => document.getElementById(id) as T; function el(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (cls) node.className = cls; return node; } function setStatus(msg: string): void { $('status').textContent = msg; } // --- 3D renderer ------------------------------------------------------------- const container = $('canvas'); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.localClippingEnabled = true; // mitre planes on the bent coping renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); container.appendChild(renderer.domElement); const world = new THREE.Scene(); world.background = new THREE.Color(0xe9eef4); const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 5000); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; world.add(new THREE.HemisphereLight(0xffffff, 0x8899aa, 1.6)); const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(30, 60, 40); world.add(sun); function resize(): void { const w = container.clientWidth; const h = container.clientHeight; if (w === 0 || h === 0) return; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); } window.addEventListener('resize', resize); resize(); function animate(): void { controls.update(); renderer.render(world, camera); requestAnimationFrame(animate); } animate(); // --- pier model ---------------------------------------------------------------- function buildEntry(input: PierInput, checked: boolean): Pier { const issues: Issue[] = []; let model: PierModel | null = null; if (validateInput(input, issues)) { try { model = buildPier(input); validatePier(input, model, issues); } catch (err) { issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message }); } } 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 { return sheets.find((s) => s.name === name)?.ok === true; } function activePier(): Pier | undefined { return state.piers.find((p) => p.input.name === state.active); } // --- parts and views ------------------------------------------------------------- function buildPartToggles(): void { const box = $('parts'); const row = el('div', undefined, 'parts-row'); for (const group of PART_GROUPS) { const label = el('label'); const input = el('input'); input.type = 'checkbox'; input.checked = group.parts.every((p) => state.visible[p]); input.addEventListener('change', () => { for (const p of group.parts) { state.visible[p] = input.checked; if (state.scene) state.scene.parts[p].visible = input.checked; } }); label.append(input, document.createTextNode(group.label)); row.appendChild(label); } box.appendChild(row); } function buildViewButtons(): void { const box = $('views'); for (const v of VIEWS) { const btn = el('button', v.label, 'btn'); btn.type = 'button'; btn.setAttribute('aria-pressed', String(v.kind === state.view)); btn.addEventListener('click', () => setView(v.kind)); box.appendChild(btn); } } function setView(kind: ViewKind): void { state.view = kind; document.querySelectorAll('#views button').forEach((b, i) => { b.setAttribute('aria-pressed', String(VIEWS[i].kind === kind)); }); if (!state.scene) return; const pier = activePier(); if (!pier?.model) return; const { position, target } = viewPosition(state.scene, kind, pier.model); camera.position.copy(position); controls.target.copy(target); controls.update(); } function setupScene(): void { if (state.scene) world.remove(state.scene.root); state.scene = null; const pier = activePier(); if (pier?.model) { const scene = buildPierScene(pier.model); for (const key of Object.keys(state.visible) as PartName[]) { scene.parts[key].visible = state.visible[key]; } world.add(scene.root); state.scene = scene; setView(state.view); } } // --- main list (시안 ① 교각 목록) ---------------------------------------------------- function renderPierList(): void { const select = $('type-filter'); if (select.options.length === 0) { select.appendChild(new Option('전체', 'all')); for (const t of TYPES) select.appendChild(new Option(t, t)); } select.value = state.filter; for (const opt of Array.from(select.options)) { const count = opt.value === 'all' ? state.piers.length : state.piers.filter((p) => p.input.type === opt.value).length; opt.textContent = `${opt.value === 'all' ? '전체' : opt.value} (${count})`; } renderPierRows($('pier-items'), 'shape'); renderPierRows($('plan-pier-items'), 'plan'); const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter); $('pier-empty').hidden = state.piers.length > 0; $('select-all').textContent = allVisibleChecked(state.piers, state.filter) ? '선택 해제' : '전체 선택'; } /** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */ function renderPierRows(list: HTMLUListElement, mode: 'shape' | 'plan'): void { list.replaceChildren(); const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter); for (const pier of visible) { const li = el('li'); const check = el('input'); check.type = 'checkbox'; check.checked = pier.checked; check.disabled = !pier.model; check.setAttribute('aria-label', `${pier.input.name} 선택`); check.addEventListener('change', () => { pier.checked = check.checked; renderAll(); }); const name = el('button', pier.input.name, 'pier-name'); name.type = 'button'; name.setAttribute('aria-pressed', String(pier.input.name === state.active)); name.addEventListener('click', () => { state.active = pier.input.name; setupScene(); renderAll(); }); const badge = mode === 'plan' ? el('span', `변경 ${overrideKeys(pier).length}`, 'badge') : pier.model ? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge') : el('span', '오류', 'badge error'); li.append(check, name, badge); list.appendChild(li); } } function renderHeader(): void { const pier = activePier(); const parts = [state.project.name || '프로젝트 미지정']; if (state.project.bridge) parts.push(state.project.bridge); parts.push(`교각 ${state.piers.length}기`); $('project-line').textContent = parts.join(' · '); $('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 = checked === 0; regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성'; $('bulk-count').textContent = `선택 ${checked}기`; $('bulk-regen').disabled = checked === 0; $('bulk-copy').disabled = !activePier(); } // --- results panel (시안 ① 입력값 · 계산 결과) ---------------------------------------- type Pair = [string, string]; function fillKv(dl: HTMLElement, pairs: Pair[]): void { dl.replaceChildren(); for (const [k, v] of pairs) { const row = el('div'); row.append(el('dt', k), el('dd', v)); dl.appendChild(row); } } const f3 = (v: number) => v.toFixed(3); const pt = (p: { e: number; n: number }) => `E ${p.e.toFixed(3)}, N ${p.n.toFixed(3)}`; function renderPanels(): void { const pier = activePier(); const m = pier?.model; $('viewer-title').textContent = pier ? `${pier.input.name} · ${pier.input.type} 교각 형상` : '교각 3D 뷰'; $('origin').textContent = m ? `원점 = 교각 중심 (${pt(m.pivot)})` : ''; if (!pier) { fillKv($('inputs'), []); fillKv($('res-axis'), []); fillKv($('res-column'), []); fillKv($('res-found'), []); $('issues').replaceChildren(el('li', '교각을 불러오면 알림이 표시됩니다.')); $('issue-count').textContent = ''; return; } const inp = pier.input; const front = inp.bearings.filter((b) => b.row === '전열').length; const back = inp.bearings.filter((b) => b.row === '후열').length; const ip2 = inp.ips.find((p) => p.name === 'IP4'); fillKv($('inputs'), [ ['교각 / 기초 형식', `${inp.type} · ${inp.foundationType}`], ['코핑 상면 EL', `${f3(inp.copingTopEl)} m`], ['기초 하단 EL', `${f3(inp.footingBottomEl)} m`], ['지반고 EL', `${f3(inp.groundEl)} m`], ['코핑 폭 × 길이', `${inp.copingWidthMm} × ${inp.copingLengthMm} mm`], ['말뚝 길이', `${inp.pileLengthMm} mm`], ['IP', ip2 && ip2.angleDeg === 0 ? 'IP1–IP3 직선 (꺾임 0°)' : '꺾임 코핑'], ['받침', `전열 ${front} · 후열 ${back}`], ]); if (!m) { fillKv($('res-axis'), [['상태', '생성 불가 (알림 참조)']]); fillKv($('res-column'), []); fillKv($('res-found'), []); } else { fillKv($('res-axis'), [ ['IP 길이 / 축 방위각', `${f3(m.lengthIp)} m / ${m.azimuthDeg.toFixed(2)}°`], ['받침블럭 높이 전열 / 후열', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)} mm`], ['계단 경계 (BP1 기준)', m.segments.slice(1).map((s) => f3(s.s0)).join(' / ') + ' m'], ['횡방향 단차', '없음'], ['코핑 하면 EL 끝단 / Level', `${f3(m.endBottom)} / ${f3(m.levelBottom)} m`], ]); fillKv($('res-column'), [ ['기둥 높이', `${f3(m.derived.columnHeight)} m`], ['기둥 중심', pt(m.pivot)], ]); fillKv($('res-found'), [ ['확대기초 상면 EL', m.footings.length ? `${f3(m.footings[0].top)} m` : '없음'], ['말뚝 배치', m.derived.pileLayout ?? '없음'], ['말뚝 수 / 선단 EL', `${m.derived.pileCount} 본 / ${m.derived.pileTipEl === null ? '없음' : f3(m.derived.pileTipEl) + ' m'}`], ['흙덮개', `${f3(m.derived.soilCover)} m`], ]); } const list = $('issues'); list.replaceChildren(); if (pier.issues.length === 0) list.appendChild(el('li', '알림 없음')); for (const issue of pier.issues) { const li = el('li'); li.append(el('span', issue.code, `code ${issue.level}`), document.createTextNode(` ${issue.message}`)); list.appendChild(li); } $('issue-count').textContent = `(${pier.issues.length}건)`; } function renderDefaults(): void { const box = $('defaults'); box.replaceChildren(); const items = [ '[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용', '[미확정] Q5 말뚝 지름 600 mm (강관) 기본값', '[미확정] Q4 직접기초 버림콘크리트 적용', ...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`), ]; for (const text of items) box.appendChild(el('li', text)); } function renderAll(): void { renderHeader(); renderPierList(); renderPanels(); renderEditCard(); renderPlan(); scheduleBackup(); } // --- 속성 복사 (시안 ⑥, plan C8) --------------------------------------------------------- /** Names of the pier targets ticked in the copy dialog. */ let copySelection = new Set(); function copyTargets(): Pier[] { const src = activePier(); const sameType = $('cp-same-type').checked; return state.piers.filter((p) => p !== src && (!sameType || p.input.type === src?.input.type)); } /** Targets that are both visible in the dialog and ticked. Hidden targets are never changed. */ function selectedCopyTargets(): Pier[] { return copyTargets().filter((t) => copySelection.has(t.input.name)); } function chosenCopyItems(): CopyItem[] { return Array.from(document.querySelectorAll('#copy-dialog input[data-item]')) .filter((i) => i.checked) .map((i) => i.dataset.item as CopyItem); } function renderCopyTargets(): void { const list = $('cp-targets'); list.replaceChildren(); for (const t of copyTargets()) { const li = el('li'); const check = el('input'); check.type = 'checkbox'; check.checked = copySelection.has(t.input.name); check.setAttribute('aria-label', `${t.input.name} 대상`); check.addEventListener('change', () => { if (check.checked) copySelection.add(t.input.name); else copySelection.delete(t.input.name); renderCopyPreview(); }); li.append(check, el('span', `${t.input.name} · ${t.input.type} · ${t.input.foundationType}`)); list.appendChild(li); } } function renderCopyPreview(): void { const src = activePier(); const items = chosenCopyItems(); const body = $('cp-preview').tBodies[0]; body.replaceChildren(); let applied = 0; let skippedTotal = 0; const selected = selectedCopyTargets(); for (const t of selected) { if (!src) break; const plan = planCopy(src.draft, t.draft, items); const labels = plan.changes.map((c) => `${FIELD_LABELS[c.key]} ${c.from} → ${c.to}`); const skipped = plan.skipped.map((k) => k.reason); skippedTotal += skipped.length; if (plan.changes.length) applied++; const row = el('tr'); row.append( el('td', t.input.name), el('td', t.input.type), el('td', String(plan.changes.length)), el('td', String(skipped.length)), el('td', [...labels, ...skipped].join(' · ') || '바뀌는 값 없음'), ); body.appendChild(row); } const n = selected.length; $('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`; const apply = $('cp-apply'); apply.textContent = `대상 ${applied}기에 적용`; apply.disabled = applied === 0; } function openCopyDialog(): void { const src = activePier(); if (!src) return; $('cp-source').textContent = `${src.input.name} · ${src.input.type} · ${src.input.foundationType}`; for (const i of document.querySelectorAll('#copy-dialog input[data-item]')) { i.checked = (DEFAULT_COPY_ITEMS as string[]).includes(i.dataset.item ?? ''); } copySelection = new Set(state.piers.filter((p) => p.checked && p !== src).map((p) => p.input.name)); renderCopyTargets(); renderCopyPreview(); $('copy-dialog').showModal(); } function applyCopyDialog(): void { const src = activePier(); if (!src) return; const items = chosenCopyItems(); const regen = $('cp-regen').checked; let applied = 0; let changes = 0; const targets = new Set(selectedCopyTargets().map((t) => t.input.name)); state.piers = state.piers.map((t) => { if (t === src || !targets.has(t.input.name)) return t; const plan = planCopy(src.draft, t.draft, items); if (plan.changes.length === 0) return t; applied++; changes += plan.changes.length; const next = { ...t, draft: applyCopy(t.draft, plan) }; return regen ? regenerateEntry(next) : next; }); $('copy-dialog').close(); setupScene(); renderAll(); setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`); } // --- 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 FIELD_LABELS[key]; } 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 pier = activePier(); const btn = $('edit-regen'); if (!pier) { $('edit-fields').replaceChildren(); $('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.'; btn.textContent = '다시 생성 · 변경 0'; btn.disabled = true; return; } const status = fieldRows($('edit-fields'), pier, EDITABLE_FIELDS.map((f) => f.key)); $('edit-summary').textContent = status.pending.length ? `미반영 변경 ${status.pending.length}건. 3D 그림은 생성본을 유지합니다.` : status.overrides.length ? `기본값과 ${status.overrides.length}건 다릅니다. 생성본이 최신입니다.` : '기본값과 같습니다.'; btn.textContent = `다시 생성 · 변경 ${status.pending.length}`; btn.disabled = status.pending.length === 0; } /** One row per input key: value in the draft, the imported default beside it, and a revert button when overridden. */ function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { overrides: EditableKey[]; pending: EditableKey[] } { box.replaceChildren(); const overrides = overrideKeys(pier); const pending = pendingKeys(pier); for (const f of EDITABLE_FIELDS.filter((x) => keys.includes(x.key))) { 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); } return { overrides, pending }; } /** Footing length of the generated model, which the 열별 본수 limits depend on. */ function footingLengthOf(p: Pier): number | undefined { return p.model?.footings[0]?.length; } /** 열별 본수 −/+ per 열 (spec §5.4). Shows the spacing, the total, and a warning below the minimum spacing. */ function renderPileRows(box: HTMLDivElement, pier: Pier): void { const footingLength = footingLengthOf(pier); if (pier.input.foundationType !== '말뚝기초' || footingLength === undefined) return; const g = pileGeometry(footingLength); let counts: number[]; try { counts = resolveRowCounts(pier.draft.pileRows, g); } catch (err) { box.appendChild(el('p', (err as Error).message, 'warn-text')); return; } const layout = rowLayout(counts, g); const wrap = el('div', undefined, 'pile-rows'); wrap.appendChild(el('p', `열별 본수 · 합계 ${totalPiles(counts)}본`, 'lbl')); counts.forEach((count, j) => { const row = el('div', undefined, layout[j].belowMinimum ? 'pile-row warn' : 'pile-row'); const minus = el('button', '−', 'btn small'); minus.type = 'button'; minus.setAttribute('aria-label', `열 ${j + 1} 본수 줄이기`); minus.addEventListener('click', () => setPileRow(pier.input.name, j, -1)); const plus = el('button', '+', 'btn small'); plus.type = 'button'; plus.setAttribute('aria-label', `열 ${j + 1} 본수 늘리기`); plus.addEventListener('click', () => setPileRow(pier.input.name, j, 1)); row.append(el('span', `열 ${j + 1}`), minus, el('span', `${count}본`, 'num'), plus, el('span', `간격 ${Math.round(layout[j].spacing * 1000)} mm`, 'num')); if (layout[j].belowMinimum) row.append(el('span', `최소 ${Math.round(g.minSpacing * 1000)} mm보다 좁습니다`, 'warn-text')); wrap.appendChild(row); }); wrap.appendChild(el('p', layoutText(counts, g), 'num')); box.appendChild(wrap); } function setPileRow(name: string, row: number, delta: number): void { const p = state.piers.find((q) => q.input.name === name); const length = p && footingLengthOf(p); if (!p || length === undefined) return; const counts = resolveRowCounts(p.draft.pileRows, pileGeometry(length)); p.draft = { ...p.draft, pileRows: adjustRow(counts, row, delta) }; renderAll(); } /** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */ function renderShapeOptions(member: MemberId): void { const box = $('member-options'); box.replaceChildren(); SHAPE_GROUPS[member].forEach((group, gi) => { const row = el('div', undefined, 'shape-row'); row.appendChild(el('span', group.title, 'lbl')); for (const option of group.options) { const chip = el('button', option.label, 'chip-opt'); chip.type = 'button'; chip.setAttribute('aria-pressed', String(option.isDefault)); chip.disabled = !isSelectable(option); if (chip.disabled) chip.title = UNDEFINED_REASON; chip.addEventListener('click', () => { try { choose(member, gi, option.label); } catch (err) { setStatus((err as Error).message); } }); row.appendChild(chip); } box.appendChild(row); }); } // --- ② 형상계획 (plan.md C1) ------------------------------------------------------------- function setStage(stage: 'shape' | 'plan'): void { state.stage = stage; $('view-shape').hidden = stage !== 'shape'; $('view-plan').hidden = stage !== 'plan'; $('nav-shape').setAttribute('aria-current', String(stage === 'shape')); $('nav-plan').setAttribute('aria-current', String(stage === 'plan')); renderAll(); } function renderPlan(): void { const pier = activePier(); const members = $('members'); members.replaceChildren(); for (const m of MEMBERS) { const li = el('li'); const btn = el('button', m.label, 'member'); btn.type = 'button'; btn.setAttribute('aria-pressed', String(m.id === state.member)); const changed = pier ? overrideKeys(pier).filter((k) => m.keys.includes(k)).length : 0; btn.append(el('span', changed ? `변경 ${changed}` : '변경 0', 'badge')); btn.addEventListener('click', () => { state.member = m.id; renderAll(); }); li.appendChild(btn); members.appendChild(li); } const member = MEMBERS.find((m) => m.id === state.member)!; $('member-title').textContent = member.label; // Default and current selection are shown separately (spec §5.1). Only defaults are selectable today. $('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${defaultSummary(member.id)}`; renderShapeOptions(member.id); $('member-note').textContent = member.note; const regen = $('member-regen'); const fields = $('member-fields'); if (!pier) { fields.replaceChildren(); regen.textContent = '다시 생성 · 변경 0'; regen.disabled = true; } else { fieldRows(fields, pier, member.keys); if (member.id === 'footing') renderPileRows(fields, pier); regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`; regen.disabled = pendingKeys(pier).length === 0; } const changes = $('changes'); changes.replaceChildren(); const pending = pier ? pendingKeys(pier) : []; for (const key of pier ? overrideKeys(pier) : []) { const li = el('li', `${FIELD_LABELS[key]}: 기본 ${pier!.defaults[key] ?? '없음'} → 현재 ${pier!.draft[key] ?? '없음'}${pending.includes(key) ? ' (미반영)' : ''}`); const revert = el('button', '되돌리기', 'btn small'); revert.type = 'button'; revert.addEventListener('click', () => revertField(pier!.input.name, key)); li.appendChild(revert); changes.appendChild(li); } if (changes.childElementCount === 0) changes.appendChild(el('li', '기본값과 같습니다.')); const issues = $('plan-issues'); issues.replaceChildren(); for (const i of pier?.issues ?? []) issues.appendChild(el('li', `${i.code} ${i.message}`)); if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.')); } // --- 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) => ({ input: p.input, defaults: p.defaults, draft: p.draft })), }); } function isDirty(): boolean { return state.piers.length > 0 && snapshot() !== state.savedSnapshot; } function currentProjectText(): string { return serializeProject({ schemaVersion: SCHEMA_VERSION, 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, }); } function renderSaveStatus(): void { const saveBtn = $('save-project'); saveBtn.disabled = state.piers.length === 0; const status = $('save-status'); if (state.piers.length === 0) { status.textContent = '저장 전'; } else if (state.savedSnapshot === null || isDirty()) { status.textContent = state.savedSnapshot === null ? '저장 전 · 변경 있음' : '변경 있음 · 미저장'; } else { status.textContent = '저장됨'; } } /** 저장 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 = projectFileName(); const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); state.savedSnapshot = snapshot(); clearBackup().catch(() => undefined); renderHeader(); setStatus(`${name} 파일을 저장했습니다.`); } /** 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 = { ...parsed.project }; state.source = parsed.source; state.history = parsed.history; 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; state.savedSnapshot = saved ? snapshot() : null; setupScene(); renderAll(); } 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) { setStatus(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`); } } function scheduleBackup(): void { window.clearTimeout(backupTimer); backupTimer = window.setTimeout(() => { if (!isDirty()) return; writeBackup(currentProjectText()).catch(() => setStatus('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.')); }, 800); } async function checkBackup(): Promise { try { const backup = await readBackup(); if (!backup) return; pendingBackup = backup; $('backup-text').textContent = `저장되지 않은 작업이 있습니다 (${new Date(backup.at).toLocaleString('ko-KR')}).`; $('backup-banner').hidden = false; } catch { // Storage unavailable (private window, blocked): no recovery is offered, and the app works without it. } } // --- import dialog (시안 ④) ------------------------------------------------------------ function selectable(p: Pending, e: Pier): boolean { return !!e.model && sheetOk(p.sheets, e.input.sheet); } function renderDialog(): void { const p = state.pending; const sheetsBox = $('sheets'); sheetsBox.replaceChildren(); $('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : ''; if (!p) { sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.')); renderPreview(null); updateApply(); return; } for (const sheet of p.sheets) { const li = el('li'); const head = el('div', undefined); const label = sheet.piers === 0 ? (sheet.ok ? '보조 표 시트 (IP·받침)' : '오류') : sheet.ok ? '선택 가능' : '선택 불가 (오류)'; head.append( el('span', `${sheet.name} · ${label}`, sheet.ok ? 'ok' : 'bad'), document.createTextNode(` · 교각 ${sheet.tables.pier}표 · IP ${sheet.tables.ip}표 · 받침 ${sheet.tables.bearing}표`), ); li.appendChild(head); for (const issue of sheet.issues) { const line = el('div', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, 'small'); line.style.color = issue.level === 'E' ? 'var(--error)' : 'var(--muted)'; li.appendChild(line); } for (const entry of p.entries.filter((e) => e.input.sheet === sheet.name)) { const row = el('div', undefined, 'sheet-pier'); const check = el('input'); check.type = 'checkbox'; check.checked = entry.checked; check.disabled = !selectable(p, entry); check.setAttribute('aria-label', `${entry.input.name} 불러오기`); check.addEventListener('change', () => { entry.checked = check.checked; updateApply(); }); const name = el('button', `${entry.input.name} · ${entry.input.type}`, 'pier-name'); name.type = 'button'; name.setAttribute('aria-pressed', String(p.preview === entry.input.name)); name.addEventListener('click', () => { p.preview = entry.input.name; renderDialog(); }); row.append(check, name); li.appendChild(row); } sheetsBox.appendChild(li); } const preview = p.entries.find((e) => e.input.name === p.preview) ?? p.entries.find((e) => selectable(p, e)) ?? null; renderPreview(preview ? preview.input : null); updateApply(); } function renderPreview(input: PierInput | null): void { const title = $('preview-title'); const pierTable = $('pv-pier').tBodies[0]; const bearingBody = $('pv-bearings').tBodies[0]; const ipBody = $('pv-ips').tBodies[0]; pierTable.replaceChildren(); bearingBody.replaceChildren(); ipBody.replaceChildren(); if (!input) { title.textContent = '미리볼 교각이 없습니다.'; return; } title.textContent = `${input.name} · 시트 ${input.sheet}`; const pairs: Pair[] = [ ['교각명', input.name], ['교각형식', input.type], ['두부보 EL (코핑 상면)', f3(input.copingTopEl)], ['기초 하단 EL', f3(input.footingBottomEl)], ['지반고 EL', f3(input.groundEl)], ['기초 형식', input.foundationType], ['말뚝길이 (mm)', String(input.pileLengthMm)], ['두부보 폭 × 길이 (mm)', `${input.copingWidthMm} × ${input.copingLengthMm}`], ['기둥갯수', String(input.columnCount)], ]; for (const [k, v] of pairs) { const tr = el('tr'); tr.append(el('th', k), el('td', v)); pierTable.appendChild(tr); } for (const b of input.bearings) { const tr = el('tr'); const azimuth = Number.isNaN(b.azimuthDeg) ? '오류' : `${b.azimuthDeg.toFixed(4)}°`; for (const v of [String(b.no), b.x.toFixed(3), b.y.toFixed(3), f3(b.z), azimuth, b.row]) tr.appendChild(el('td', v)); bearingBody.appendChild(tr); } for (const ip of input.ips) { const tr = el('tr'); for (const v of [ip.name, ip.x.toFixed(3), ip.y.toFixed(3), `${ip.angleDeg}°`]) tr.appendChild(el('td', v)); ipBody.appendChild(tr); } } function updateApply(): void { const p = state.pending; const count = p ? p.entries.filter((e) => e.checked && selectable(p, e)).length : 0; const total = p ? p.entries.filter((e) => selectable(p, e)).length : 0; $('dlg-selected').textContent = `선택 ${count} / ${total}기`; const named = $('project-name').value.trim().length > 0; const apply = $('dlg-apply'); apply.textContent = `선택한 교각 ${count}기 형상 생성`; apply.disabled = count === 0 || !named; } function loadBytes(bytes: Uint8Array, fileName: string): void { const { piers, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' })); const entries = piers.map((input) => buildEntry(input, false)); // A sheet is selectable only if every pier in it builds. Show the reason next to the sheet. for (const sheet of sheets) { const own = entries.filter((e) => e.input.sheet === sheet.name); for (const e of own) sheet.issues.push(...e.issues.filter((i) => i.level === 'E')); sheet.ok = sheet.ok && own.every((e) => e.model !== null); } const pending: Pending = { fileName, entries, sheets, preview: null }; for (const e of entries) e.checked = selectable(pending, e); pending.preview = entries.find((e) => selectable(pending, e))?.input.name ?? entries[0]?.input.name ?? null; state.pending = pending; renderDialog(); } function applyImport(): void { const p = state.pending; const name = $('project-name').value.trim(); if (!p || !name) return; 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'; state.pending = null; 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(); setStatus(`${state.piers.length}기 형상을 생성했습니다. 정보·경고는 오른쪽 알림에서 확인하세요.`); } function openImport(): void { const dlg = $('import-dialog'); if (!dlg.open) dlg.showModal(); $('project-name').focus(); } async function fetchSample(): Promise { const res = await fetch(new URL('samples/운곡교P1입력.xlsx', document.baseURI)); if (!res.ok) throw new Error(`샘플 응답 ${res.status}`); return new Uint8Array(await res.arrayBuffer()); } function regenerateChecked(): void { let errors = 0; let count = 0; state.piers = state.piers.map((p) => { if (!p.checked) return p; count++; const next = regenerateEntry(p); if (!next.model) errors++; return next; }); setupScene(); renderAll(); setStatus(`${count}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`); } // --- events ----------------------------------------------------------------------------- $('open-import').addEventListener('click', openImport); $('dlg-close').addEventListener('click', () => $('import-dialog').close()); $('dlg-cancel').addEventListener('click', () => $('import-dialog').close()); $('dlg-apply').addEventListener('click', applyImport); $('regen').addEventListener('click', regenerateChecked); $('bulk-regen').addEventListener('click', regenerateChecked); $('bulk-copy').addEventListener('click', openCopyDialog); $('nav-shape').addEventListener('click', () => setStage('shape')); $('nav-plan').addEventListener('click', () => setStage('plan')); $('member-regen').addEventListener('click', () => { const pier = activePier(); if (pier) regenerateOne(pier.input.name); }); $('cp-same-type').addEventListener('change', () => { renderCopyTargets(); renderCopyPreview(); }); for (const i of document.querySelectorAll('#copy-dialog input[data-item]')) { i.addEventListener('change', renderCopyPreview); } $('cp-regen').addEventListener('change', renderCopyPreview); $('cp-apply').addEventListener('click', applyCopyDialog); $('cp-cancel').addEventListener('click', () => $('copy-dialog').close()); $('cp-close').addEventListener('click', () => $('copy-dialog').close()); $('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); }); $('project-name').addEventListener('input', updateApply); $('pick-file').addEventListener('click', () => $('file').click()); $('download-template').addEventListener('click', () => { XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx'); }); $('file').addEventListener('change', async (ev) => { const file = (ev.target as HTMLInputElement).files?.[0]; if (!file) return; try { loadBytes(new Uint8Array(await file.arrayBuffer()), file.name); } catch (err) { setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`); } }); $('pick-sample').addEventListener('click', async () => { try { loadBytes(await fetchSample(), '샘플 P1 (운곡교P1입력.xlsx)'); } catch (err) { setStatus(`샘플을 불러오지 못했습니다: ${(err as Error).message}`); } }); const drop = $('drop'); drop.addEventListener('dragover', (ev) => { ev.preventDefault(); drop.classList.add('over'); }); drop.addEventListener('dragleave', () => drop.classList.remove('over')); drop.addEventListener('drop', async (ev) => { ev.preventDefault(); drop.classList.remove('over'); const file = ev.dataTransfer?.files?.[0]; if (!file) return; try { loadBytes(new Uint8Array(await file.arrayBuffer()), file.name); } catch (err) { setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`); } }); $('type-filter').addEventListener('change', (ev) => { state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType; renderPierList(); }); $('select-all').addEventListener('click', () => { state.piers = setVisibleChecked(state.piers, state.filter, !allVisibleChecked(state.piers, state.filter)); renderAll(); }); $('save-project').addEventListener('click', saveProject); $('open-project').addEventListener('click', () => $('project-file').click()); $('project-file').addEventListener('change', (ev) => { const file = (ev.target as HTMLInputElement).files?.[0]; if (file) void openProjectFile(file); (ev.target as HTMLInputElement).value = ''; }); window.addEventListener('keydown', (ev) => { if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 's') { ev.preventDefault(); saveProject(); } }); window.addEventListener('beforeunload', (ev) => { if (isDirty()) ev.preventDefault(); }); $('restore-backup').addEventListener('click', () => { if (!pendingBackup) return; try { openProjectText(pendingBackup.text, false); setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.'); } catch (err) { setStatus(`백업을 복구하지 못했습니다: ${(err as Error).message}`); } pendingBackup = null; $('backup-banner').hidden = true; }); $('discard-backup').addEventListener('click', () => { pendingBackup = null; $('backup-banner').hidden = true; clearBackup().catch(() => undefined); }); // --- start ------------------------------------------------------------------------------ buildPartToggles(); buildViewButtons(); renderDefaults(); renderDialog(); renderAll(); void checkBackup(); // Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog. if (new URLSearchParams(window.location.search).get('sample') === '1') { fetchSample() .then((bytes) => { loadBytes(bytes, '샘플 P1'); $('project-name').value = '운곡교 P1 샘플'; $('bridge-name').value = '운곡교'; applyImport(); }) .catch((err: Error) => setStatus(`샘플을 불러오지 못했습니다: ${err.message}`)); }