Files
Pier_v1/src/main.ts
T
ypshimandClaude Haiku 5.5 5b162c2441 feat(core,view): shape-option rules (plan C5) and pier selection rules (plan C6)
- C5: each member's options come from one table. Only the defaults (the 1단계
  model) are selectable. The others show as disabled chips with 규칙 미정 (Q6).
  The member summary is built from the defaults.
- C6: 전체 선택 acts only on visible, buildable piers; it reads 선택 해제 only
  when all of those are checked. A check survives a change of the type filter.
  The pier list scrolls past 300 px, for 20 piers.
- Tests: defaults are the only choices; unknown options refused; the 20-pier
  mixed list with two failed piers; filter and check persistence.
- Not verified in a browser.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-22 10:15:03 +09:00

1143 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
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<PartName, boolean>,
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 $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
function el<K extends keyof HTMLElementTagNameMap>(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 = $<HTMLDivElement>('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 = $<HTMLDivElement>('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 = $<HTMLDivElement>('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<HTMLButtonElement>('#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 = $<HTMLSelectElement>('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($<HTMLUListElement>('pier-items'), 'shape');
renderPierRows($<HTMLUListElement>('plan-pier-items'), 'plan');
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
$<HTMLButtonElement>('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 = $<HTMLButtonElement>('regen');
regen.disabled = checked === 0;
regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성';
$('bulk-count').textContent = `선택 ${checked}기`;
$<HTMLButtonElement>('bulk-regen').disabled = checked === 0;
$<HTMLButtonElement>('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 = $<HTMLUListElement>('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 = $<HTMLUListElement>('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<string>();
function copyTargets(): Pier[] {
const src = activePier();
const sameType = $<HTMLInputElement>('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<HTMLInputElement>('#copy-dialog input[data-item]'))
.filter((i) => i.checked)
.map((i) => i.dataset.item as CopyItem);
}
function renderCopyTargets(): void {
const list = $<HTMLUListElement>('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 = $<HTMLTableElement>('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) => `${EDITABLE_FIELDS.find((f) => f.key === c.key)!.label} ${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 = $<HTMLButtonElement>('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<HTMLInputElement>('#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();
$<HTMLDialogElement>('copy-dialog').showModal();
}
function applyCopyDialog(): void {
const src = activePier();
if (!src) return;
const items = chosenCopyItems();
const regen = $<HTMLInputElement>('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;
});
$<HTMLDialogElement>('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 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 pier = activePier();
const btn = $<HTMLButtonElement>('edit-regen');
if (!pier) {
$<HTMLDivElement>('edit-fields').replaceChildren();
$('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.';
btn.textContent = '다시 생성 · 변경 0';
btn.disabled = true;
return;
}
const status = fieldRows($<HTMLDivElement>('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 };
}
/** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */
function renderShapeOptions(member: MemberId): void {
const box = $<HTMLDivElement>('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;
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
$<HTMLElement>('view-plan').hidden = stage !== 'plan';
$<HTMLButtonElement>('nav-shape').setAttribute('aria-current', String(stage === 'shape'));
$<HTMLButtonElement>('nav-plan').setAttribute('aria-current', String(stage === 'plan'));
renderAll();
}
function renderPlan(): void {
const pier = activePier();
const members = $<HTMLUListElement>('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 = $<HTMLButtonElement>('member-regen');
const fields = $<HTMLDivElement>('member-fields');
if (!pier) {
fields.replaceChildren();
regen.textContent = '다시 생성 · 변경 0';
regen.disabled = true;
} else {
fieldRows(fields, pier, member.keys);
regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`;
regen.disabled = pendingKeys(pier).length === 0;
}
const changes = $<HTMLUListElement>('changes');
changes.replaceChildren();
const pending = pier ? pendingKeys(pier) : [];
for (const key of pier ? overrideKeys(pier) : []) {
const f = EDITABLE_FIELDS.find((x) => x.key === key)!;
const li = el('li', `${f.label}: 기본 ${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 = $<HTMLUListElement>('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 = $<HTMLButtonElement>('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 {
$<HTMLInputElement>('sv-name').value = state.project.name;
$<HTMLInputElement>('sv-bridge').value = state.project.bridge;
$<HTMLInputElement>('sv-client').value = state.project.client;
$<HTMLInputElement>('sv-author').value = state.project.author;
$<HTMLTextAreaElement>('sv-memo').value = state.project.memo;
$('sv-file').textContent = projectFileName();
$<HTMLDialogElement>('save-dialog').showModal();
$<HTMLInputElement>('sv-name').focus();
}
function confirmSaveDialog(): void {
const name = $<HTMLInputElement>('sv-name').value.trim();
if (!name) {
$<HTMLInputElement>('sv-name').focus();
setStatus('프로젝트명을 입력하세요.');
return;
}
state.project = {
name,
bridge: $<HTMLInputElement>('sv-bridge').value.trim(),
client: $<HTMLInputElement>('sv-client').value.trim(),
author: $<HTMLInputElement>('sv-author').value.trim(),
crs: CRS,
memo: $<HTMLTextAreaElement>('sv-memo').value.trim(),
};
$<HTMLDialogElement>('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<void> {
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<void> {
try {
const backup = await readBackup();
if (!backup) return;
pendingBackup = backup;
$('backup-text').textContent = `저장되지 않은 작업이 있습니다 (${new Date(backup.at).toLocaleString('ko-KR')}).`;
$<HTMLDivElement>('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 = $<HTMLUListElement>('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 = $<HTMLTableElement>('pv-pier').tBodies[0];
const bearingBody = $<HTMLTableElement>('pv-bearings').tBodies[0];
const ipBody = $<HTMLTableElement>('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 = $<HTMLInputElement>('project-name').value.trim().length > 0;
const apply = $<HTMLButtonElement>('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 = $<HTMLInputElement>('project-name').value.trim();
if (!p || !name) return;
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('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;
$<HTMLDialogElement>('import-dialog').close();
setupScene();
renderAll();
setStatus(`${state.piers.length}기 형상을 생성했습니다. 정보·경고는 오른쪽 알림에서 확인하세요.`);
}
function openImport(): void {
const dlg = $<HTMLDialogElement>('import-dialog');
if (!dlg.open) dlg.showModal();
$<HTMLInputElement>('project-name').focus();
}
async function fetchSample(): Promise<Uint8Array> {
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 -----------------------------------------------------------------------------
$<HTMLButtonElement>('open-import').addEventListener('click', openImport);
$<HTMLButtonElement>('dlg-close').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
$<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shape'));
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
$<HTMLButtonElement>('member-regen').addEventListener('click', () => {
const pier = activePier();
if (pier) regenerateOne(pier.input.name);
});
$<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
renderCopyTargets();
renderCopyPreview();
});
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
i.addEventListener('change', renderCopyPreview);
}
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyDialog);
$<HTMLButtonElement>('cp-cancel').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
$<HTMLButtonElement>('cp-close').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
$<HTMLButtonElement>('sv-cancel').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
$<HTMLButtonElement>('sv-close').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
$<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());
$<HTMLButtonElement>('download-template').addEventListener('click', () => {
XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx');
});
$<HTMLInputElement>('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}`);
}
});
$<HTMLButtonElement>('pick-sample').addEventListener('click', async () => {
try {
loadBytes(await fetchSample(), '샘플 P1 (운곡교P1입력.xlsx)');
} catch (err) {
setStatus(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
}
});
const drop = $<HTMLDivElement>('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}`);
}
});
$<HTMLSelectElement>('type-filter').addEventListener('change', (ev) => {
state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType;
renderPierList();
});
$<HTMLButtonElement>('select-all').addEventListener('click', () => {
state.piers = setVisibleChecked(state.piers, state.filter, !allVisibleChecked(state.piers, state.filter));
renderAll();
});
$<HTMLButtonElement>('save-project').addEventListener('click', saveProject);
$<HTMLButtonElement>('open-project').addEventListener('click', () => $<HTMLInputElement>('project-file').click());
$<HTMLInputElement>('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();
});
$<HTMLButtonElement>('restore-backup').addEventListener('click', () => {
if (!pendingBackup) return;
try {
openProjectText(pendingBackup.text, false);
setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.');
} catch (err) {
setStatus(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
}
pendingBackup = null;
$<HTMLDivElement>('backup-banner').hidden = true;
});
$<HTMLButtonElement>('discard-backup').addEventListener('click', () => {
pendingBackup = null;
$<HTMLDivElement>('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');
$<HTMLInputElement>('project-name').value = '운곡교 P1 샘플';
$<HTMLInputElement>('bridge-name').value = '운곡교';
applyImport();
})
.catch((err: Error) => setStatus(`샘플을 불러오지 못했습니다: ${err.message}`));
}