- buildTemplateWorkbook: 교각 · IP · 받침 sheets with the headers the parser reads; value cells left empty so no blank-name rows raise E05 - dialog button 입력 템플릿 다운로드 (.xlsx) - tests: sheet order, parser recognition, write-and-read round trip Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
591 lines
22 KiB
TypeScript
591 lines
22 KiB
TypeScript
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 type { Issue, PierInput, PierType } from './core/types';
|
||
import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
||
|
||
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
|
||
interface Pier {
|
||
input: PierInput;
|
||
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주형'];
|
||
|
||
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: '' },
|
||
filter: 'all' as 'all' | PierType,
|
||
pending: null as Pending | null,
|
||
};
|
||
|
||
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.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 };
|
||
}
|
||
|
||
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})`;
|
||
}
|
||
|
||
const list = $<HTMLUListElement>('pier-items');
|
||
list.replaceChildren();
|
||
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
|
||
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
|
||
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 = 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);
|
||
}
|
||
|
||
const allChecked = visible.length > 0 && visible.every((p) => p.checked);
|
||
$<HTMLButtonElement>('select-all').textContent = allChecked ? '선택 해제' : '전체 선택';
|
||
}
|
||
|
||
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 ? '생성됨' : '미생성';
|
||
$<HTMLButtonElement>('regen').disabled = !state.piers.some((p) => p.checked);
|
||
}
|
||
|
||
// --- 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();
|
||
}
|
||
|
||
// --- 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 = { 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;
|
||
$<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 = buildEntry(p.input, true);
|
||
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);
|
||
$<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 (Pier ui_xxx.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', () => {
|
||
const visible = state.piers.filter((p) => (state.filter === 'all' || p.input.type === state.filter) && p.model);
|
||
const all = visible.every((p) => p.checked);
|
||
for (const p of visible) p.checked = !all;
|
||
renderAll();
|
||
});
|
||
|
||
// --- start ------------------------------------------------------------------------------
|
||
buildPartToggles();
|
||
buildViewButtons();
|
||
renderDefaults();
|
||
renderDialog();
|
||
renderAll();
|
||
|
||
// 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}`));
|
||
}
|