Files
Pier_v1/src/main.ts
T
ypshimandClaude Haiku 5.5 69922cfa35 feat(ui): align stage-1 screens with the design mockup
- main screen follows 시안 ①: header steps, pier list with type filter and
  multi-select, viewer with 아이소/정면/측면/평면, part toggles in mockup order,
  input panel and result sections
- import moved to a modal dialog following 시안 ④: project name (required),
  file drop and pick, per-sheet recognition with selectable piers, preview of
  pier and bearing tables, glossary of Excel column names
- Esc closes the dialog and focus stays inside it (native dialog)
- 44px touch targets and 2px focus rings; colours from the mockup tokens

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-21 17:49:04 +09:00

579 lines
22 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 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);
head.append(
el('span', `${sheet.name} · ${sheet.ok ? '선택 가능' : '선택 불가 (오류)'}`, 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));
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());
$<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}`));
}