-
-
+
+
+
+
엑셀을 불러오면 교각 형상을 생성합니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main.ts b/src/main.ts
index d0470ce..cb15382 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,36 +5,69 @@ 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 } from './core/types';
-import { buildPierScene, PART_LABELS, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
+import type { Issue, PierInput, PierType } from './core/types';
+import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
-interface Loaded {
+/** 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
,
+ scene: null as PierScene | null,
+ project: { name: '', bridge: '' },
+ filter: 'all' as 'all' | PierType,
+ pending: null as Pending | null,
+};
+
const $ = (id: string): T => document.getElementById(id) as T;
-const VIEW_LABELS: Record = { iso: '아이소', front: '정면', side: '측면', top: '평면' };
+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;
+}
-const state: {
- piers: Loaded[];
- parseIssues: Issue[];
- sheets: SheetReport[];
- current: { loaded: Loaded; scene: PierScene } | null;
- view: ViewKind;
- visible: Record;
-} = {
- piers: [],
- parseIssues: [],
- sheets: [],
- current: null,
- view: 'iso',
- visible: Object.fromEntries(Object.keys(PART_LABELS).map((k) => [k, true])) as Record,
-};
+function setStatus(msg: string): void {
+ $('status').textContent = msg;
+}
-// --- 3D renderer -----------------------------------------------------------
+// --- 3D renderer -------------------------------------------------------------
const container = $('canvas');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
@@ -52,6 +85,7 @@ 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();
@@ -66,37 +100,57 @@ function animate(): void {
}
animate();
-// --- UI builders (textContent only: workbook text is never parsed as HTML) -------
-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;
+// --- 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 = $('parts');
- for (const key of Object.keys(PART_LABELS) as PartName[]) {
+ 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 = state.visible[key];
+ input.checked = group.parts.every((p) => state.visible[p]);
input.addEventListener('change', () => {
- state.visible[key] = input.checked;
- if (state.current) state.current.scene.parts[key].visible = input.checked;
+ 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(PART_LABELS[key]));
- box.appendChild(label);
+ label.append(input, document.createTextNode(group.label));
+ row.appendChild(label);
}
+ box.appendChild(row);
}
function buildViewButtons(): void {
const box = $('views');
- for (const kind of Object.keys(VIEW_LABELS) as ViewKind[]) {
- const btn = el('button', VIEW_LABELS[kind]);
+ for (const v of VIEWS) {
+ const btn = el('button', v.label, 'btn');
btn.type = 'button';
- btn.setAttribute('aria-pressed', String(kind === state.view));
- btn.addEventListener('click', () => setView(kind));
+ btn.setAttribute('aria-pressed', String(v.kind === state.view));
+ btn.addEventListener('click', () => setView(v.kind));
box.appendChild(btn);
}
}
@@ -104,188 +158,421 @@ function buildViewButtons(): void {
function setView(kind: ViewKind): void {
state.view = kind;
document.querySelectorAll('#views button').forEach((b, i) => {
- b.setAttribute('aria-pressed', String(Object.keys(VIEW_LABELS)[i] === kind));
+ b.setAttribute('aria-pressed', String(VIEWS[i].kind === kind));
});
- if (!state.current) return;
- const { position, target } = viewPosition(state.current.scene, kind, state.current.loaded.model!);
+ 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();
}
-// --- Loading ----------------------------------------------------------------------
-function loadWorkbookBytes(bytes: Uint8Array): void {
- const { piers, issues: parseIssues, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' }));
- const loaded: Loaded[] = piers.map((input) => {
- 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 });
- }
+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];
}
- return { input, model, issues };
- });
- state.piers = loaded;
- state.parseIssues = parseIssues;
- state.sheets = sheets;
- renderSheets();
-
- // Only piers from sheets without E-level issues can be selected (spec §2.3).
- const selectable = (l: Loaded) => !!l.model && sheets.find((s) => s.name === l.input.sheet)?.ok === true;
- const select = $('pier');
- select.replaceChildren();
- for (const item of loaded.filter(selectable)) {
- select.appendChild(new Option(item.input.name, item.input.name));
+ world.add(scene.root);
+ state.scene = scene;
+ setView(state.view);
}
- select.disabled = select.options.length === 0;
- const built = loaded.filter(selectable).length;
- $('status').textContent = `시트 ${sheets.length}장 · 교각 ${piers.length}기 인식 · 선택 가능 ${built}기`;
- const first = loaded.find(selectable);
- if (first) showPier(first.input.name);
- else showPier(null);
}
-function showPier(name: string | null): void {
- const select = $('pier');
- if (state.current) world.remove(state.current.scene.root);
- state.current = null;
- if (!name) {
- renderResults(null);
- return;
+// --- 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));
}
- const loaded = state.piers.find((l) => l.input.name === name && l.model);
- if (!loaded || !loaded.model) return;
- const scene = buildPierScene(loaded.model);
- for (const key of Object.keys(PART_LABELS) as PartName[]) {
- scene.parts[key].visible = state.visible[key];
+ 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})`;
}
- world.add(scene.root);
- state.current = { loaded, scene };
- select.value = name;
- setView(state.view);
- renderResults(loaded);
+
+ const list = $('pier-items');
+ list.replaceChildren();
+ const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
+ $('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);
+ $('select-all').textContent = allChecked ? '선택 해제' : '전체 선택';
}
-// --- Results ------------------------------------------------------------------------
-function row(table: HTMLTableSectionElement, label: string, value: string): void {
- const tr = el('tr');
- tr.append(el('td', label), el('td', value));
- table.appendChild(tr);
+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 ? '생성됨' : '미생성';
+ $('regen').disabled = !state.piers.some((p) => p.checked);
}
-const fmt = (v: number, d = 3) => v.toFixed(d);
+// --- 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 renderResults(loaded: Loaded | null): void {
- const body = $('summary').tBodies[0] ?? $('summary').createTBody();
- body.replaceChildren();
- const issuesBox = $('issues');
- issuesBox.replaceChildren();
+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)})` : '';
- const m = loaded?.model;
- if (m && loaded) {
- row(body, '교각', loaded.input.name);
- row(body, '교각축 길이 L_IP (m)', fmt(m.lengthIp));
- row(body, '교각축 방위각 (°)', fmt(m.azimuthDeg, 2));
- row(body, 'BP1 (국토좌표 m)', pt(m.bp1));
- row(body, 'BP2 (국토좌표 m)', pt(m.bp2));
- row(body, 'd_IP2 (m)', fmt(m.dIp2));
- row(body, '기둥 중심 (국토좌표 m)', pt(m.pivot));
- row(body, '코핑 상면 EL 기준 (m)', fmt(loaded.input.copingTopEl));
- row(body, '전열 구간 상면 EL (m)', m.segments.map((s) => fmt(s.top)).join(' / '));
- row(body, '계단 경계 (BP1 기준, m)', m.segments.slice(1).map((s) => fmt(s.s0)).join(' / '));
- row(body, '횡방향 단차', '없음');
- row(body, '코핑 끝단 하면 EL (m)', fmt(m.endBottom));
- row(body, '코핑 Level 하면 EL (m)', fmt(m.levelBottom));
- row(body, '기둥 높이 (m)', fmt(m.derived.columnHeight));
- row(body, '확대기초 상면 EL (m)', m.footings.length ? fmt(m.footings[0].top) : '없음');
- row(body, '흙덮개 (m)', fmt(m.derived.soilCover));
- row(body, '말뚝 수 (본)', String(m.derived.pileCount));
- row(body, '말뚝 배치', m.derived.pileLayout ?? '없음');
- row(body, '말뚝 선단 EL (m)', m.derived.pileTipEl === null ? '없음' : fmt(m.derived.pileTipEl));
- row(body, '받침블럭 콘크리트 전열 / 후열 (mm)', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)}`);
+ 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 {
- row(body, '상태', '교각을 불러오면 결과가 표시됩니다.');
+ 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 allIssues = [...state.parseIssues, ...state.piers.flatMap((l) => l.issues)];
- if (allIssues.length === 0) {
- issuesBox.appendChild(el('li', '알림 없음'));
- }
- for (const issue of allIssues) {
+ 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'), document.createTextNode(` ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`));
- issuesBox.appendChild(li);
+ li.append(el('span', issue.code, `code ${issue.level}`), document.createTextNode(` ${issue.message}`));
+ list.appendChild(li);
}
+ $('issue-count').textContent = `(${pier.issues.length}건)`;
+}
- const defaults = $('defaults');
- defaults.replaceChildren();
+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) defaults.appendChild(el('li', text));
+ for (const text of items) box.appendChild(el('li', text));
}
-function renderSheets(): void {
- const box = $('sheets');
- box.replaceChildren();
- if (state.sheets.length === 0) {
- box.appendChild(el('li', '엑셀을 불러오면 시트별 인식 결과가 표시됩니다.'));
+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 = $('sheets');
+ sheetsBox.replaceChildren();
+ $('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : '';
+ if (!p) {
+ sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.'));
+ renderPreview(null);
+ updateApply();
return;
}
- for (const s of state.sheets) {
+
+ for (const sheet of p.sheets) {
const li = el('li');
- const status = s.ok ? '선택 가능' : '선택 불가 (오류)';
- const head = el('span', `${s.name} · ${status}`, s.ok ? 'sheet-ok' : 'code');
- const detail = document.createTextNode(` · 교각 ${s.tables.pier}표 · IP ${s.tables.ip}표 · 받침 ${s.tables.bearing}표`);
- li.append(head, detail);
- box.appendChild(li);
- for (const issue of s.issues) {
- const sub = el('li', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, issue.level === 'E' ? 'code' : undefined);
- sub.style.marginLeft = '16px';
- box.appendChild(sub);
+ 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 = $('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);
}
}
-// --- Events -----------------------------------------------------------------------
-$('sample').addEventListener('click', async () => {
- $('status').textContent = '샘플 불러오는 중…';
- try {
- const res = await fetch(new URL('samples/P1.xlsx', document.baseURI));
- loadWorkbookBytes(new Uint8Array(await res.arrayBuffer()));
- } catch (err) {
- $('status').textContent = `샘플을 불러오지 못했습니다: ${(err as Error).message}`;
- }
-});
+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));
+ 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 = { 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;
+ $('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 = buildEntry(p.input, true);
+ 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);
+$('project-name').addEventListener('input', updateApply);
+
+$('pick-file').addEventListener('click', () => $('file').click());
$('file').addEventListener('change', async (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
- $('status').textContent = `${file.name} 읽는 중…`;
try {
- loadWorkbookBytes(new Uint8Array(await file.arrayBuffer()));
+ loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
} catch (err) {
- $('status').textContent = `엑셀을 읽지 못했습니다: ${(err as Error).message}`;
+ setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
+ }
+});
+$('pick-sample').addEventListener('click', async () => {
+ try {
+ loadBytes(await fetchSample(), '샘플 P1 (Pier ui_xxx.xlsx)');
+ } catch (err) {
+ setStatus(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
}
});
-$('pier').addEventListener('change', (ev) => showPier((ev.target as HTMLSelectElement).value));
+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', () => {
+ 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();
-renderSheets();
-renderResults(null);
+renderDefaults();
+renderDialog();
+renderAll();
-// Deep link for the bundled sample: open with ?sample=1 to load P1 without clicking.
+// Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog.
if (new URLSearchParams(window.location.search).get('sample') === '1') {
- $('sample').click();
+ fetchSample()
+ .then((bytes) => {
+ loadBytes(bytes, '샘플 P1');
+ $('project-name').value = '운곡교 P1 샘플';
+ $('bridge-name').value = '운곡교';
+ applyImport();
+ })
+ .catch((err: Error) => setStatus(`샘플을 불러오지 못했습니다: ${err.message}`));
}
diff --git a/src/style.css b/src/style.css
index c194302..fde2c94 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,25 +1,16 @@
+/* Tokens from 시안 (RAG/Pierzainer UI 시안 (1).html): background, primary action, dimension colour. */
:root {
- --bg: #f5f7fa;
+ --bg: #faf9f5;
--panel: #ffffff;
--text: #1d2733;
--muted: #5b6878;
- --line: #d5dce4;
+ --line: #d9d6cc;
--primary: #1f5fa8;
- --warn: #9a5b00;
+ --dim: #4a5868;
+ --warn: #8a5300;
+ --error: #9b2c2c;
--focus: 2px solid #1f5fa8;
- color-scheme: light dark;
-}
-
-@media (prefers-color-scheme: dark) {
- :root:not([data-theme='light']) {
- --bg: #111820;
- --panel: #1a232d;
- --text: #e6ebf0;
- --muted: #9aa7b4;
- --line: #2d3a47;
- --primary: #5b93d6;
- --warn: #e0a24a;
- }
+ color-scheme: light;
}
* {
@@ -33,172 +24,341 @@ body {
font: 14px/1.5 system-ui, 'Noto Sans KR', sans-serif;
}
-button,
-select,
-input,
-label {
- font: inherit;
+h2 {
+ font-size: 16px;
+ margin: 0 0 10px;
+}
+
+h3 {
+ font-size: 14px;
+ margin: 0 0 6px;
+}
+
+h4 {
+ font-size: 13px;
+ margin: 12px 0 6px;
+ color: var(--muted);
+}
+
+.muted {
+ color: var(--muted);
+}
+
+.small {
+ font-size: 12px;
}
button,
select,
-.file {
+input {
+ font: inherit;
min-height: 44px;
}
button:focus-visible,
select:focus-visible,
-input:focus-visible {
+input:focus-visible,
+summary:focus-visible {
outline: var(--focus);
outline-offset: 2px;
}
+.btn {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--panel);
+ color: var(--text);
+ padding: 0 14px;
+ cursor: pointer;
+}
+
+.btn.small {
+ min-height: 44px;
+ padding: 0 10px;
+}
+
+.btn.primary {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: #fff;
+}
+
+.btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.btn[aria-pressed='true'] {
+ border-color: var(--primary);
+ color: var(--primary);
+ font-weight: 600;
+}
+
+/* --- header ------------------------------------------------------------- */
+
+.app {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
.top {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
- padding: 12px 16px;
- border-bottom: 1px solid var(--line);
+ padding: 10px 16px;
background: var(--panel);
+ border-bottom: 1px solid var(--line);
}
-.top h1 {
- font-size: 18px;
- margin: 0;
+.brand {
+ display: flex;
+ gap: 10px;
+ align-items: baseline;
}
-.stage {
- font-size: 13px;
+.brand strong {
+ font-size: 16px;
+}
+
+.steps {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.step {
+ padding: 8px 12px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
color: var(--muted);
+}
+
+.step.current {
+ border-color: var(--primary);
+ color: var(--text);
+ font-weight: 600;
+}
+
+.step em {
+ font-style: normal;
font-weight: 400;
- margin-left: 8px;
+ color: var(--muted);
+ margin-left: 4px;
}
.actions {
display: flex;
gap: 8px;
- align-items: center;
flex-wrap: wrap;
}
-.primary {
- background: var(--primary);
- color: #fff;
- border: 0;
- border-radius: 6px;
- padding: 0 14px;
- cursor: pointer;
+.status-line {
+ margin: 0;
+ padding: 8px 16px;
+ color: var(--muted);
+ font-size: 13px;
}
-.file {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 0 10px;
- border: 1px solid var(--line);
- border-radius: 6px;
- cursor: pointer;
-}
+/* --- layout ------------------------------------------------------------- */
-.file input {
- font-size: 12px;
- max-width: 220px;
-}
-
-.layout {
+.grid {
display: grid;
- grid-template-columns: 240px 1fr 320px;
+ grid-template-columns: 260px minmax(0, 1fr) 320px;
gap: 12px;
- padding: 12px 16px;
- min-height: calc(100vh - 80px);
+ padding: 0 16px 12px;
+ flex: 1;
}
-.side,
-.results,
-.viewer {
+.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
+ min-width: 0;
}
-.side section,
-.results section {
- margin-bottom: 16px;
+.inputs {
+ margin: 0 16px 16px;
}
-h2 {
- font-size: 15px;
- margin: 0 0 8px;
+/* --- pier list ---------------------------------------------------------- */
+
+.pier-tools {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 8px;
+ flex-wrap: wrap;
}
-.muted {
- color: var(--muted);
+.pier-tools label {
+ display: flex;
+ gap: 6px;
+ align-items: center;
}
select {
- width: 100%;
- padding: 6px;
+ padding: 4px 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--text);
}
-.parts label,
-.views button {
- display: flex;
+.pier-items {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ max-height: 70vh;
+ overflow: auto;
+}
+
+.pier-items li {
+ display: grid;
+ grid-template-columns: 28px 1fr auto;
align-items: center;
- gap: 8px;
- min-height: 44px;
+ gap: 6px;
+ border-bottom: 1px solid var(--line);
+ padding: 2px 0;
+}
+
+.pier-items input[type='checkbox'] {
+ width: 28px;
+ height: 28px;
+ margin: 8px;
+}
+
+.pier-name {
+ border: 0;
+ background: none;
+ text-align: left;
+ color: var(--text);
+ cursor: pointer;
+ padding: 0 4px;
+}
+
+.pier-name[aria-pressed='true'] {
+ color: var(--primary);
+ font-weight: 700;
+}
+
+.badge {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.badge.error {
+ color: var(--error);
+ font-weight: 600;
+}
+
+/* --- viewer ------------------------------------------------------------- */
+
+.viewer-head {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.viewer-head h2 {
+ margin: 0;
}
.views {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
}
-.views button {
- border: 1px solid var(--line);
- border-radius: 6px;
- background: var(--panel);
- color: var(--text);
- justify-content: center;
- cursor: pointer;
+.views .btn {
+ min-width: 64px;
}
-.views button[aria-pressed='true'] {
- border-color: var(--primary);
- color: var(--primary);
- font-weight: 600;
+#origin {
+ margin-left: auto;
+ font-size: 12px;
}
.canvas {
width: 100%;
- height: calc(100vh - 140px);
+ height: 62vh;
min-height: 420px;
border-radius: 6px;
overflow: hidden;
background: #e9eef4;
}
-table {
- width: 100%;
- border-collapse: collapse;
+.parts {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ margin: 10px 0 0;
+ padding: 6px 10px;
}
-td {
- padding: 4px 6px;
- border-bottom: 1px solid var(--line);
- vertical-align: top;
-}
-
-td:first-child {
+.parts legend {
color: var(--muted);
- width: 48%;
+ font-size: 12px;
+ padding: 0 4px;
+}
+
+.parts-row {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.parts-row label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 44px;
+ padding: 0 8px;
+}
+
+.parts-row input {
+ width: 22px;
+ height: 22px;
+}
+
+/* --- results ------------------------------------------------------------ */
+
+.results section {
+ border-bottom: 1px solid var(--line);
+ padding-bottom: 8px;
+ margin-bottom: 8px;
+}
+
+.kv {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 2px 8px;
+ margin: 0;
+}
+
+.kv.wide {
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+}
+
+.kv div {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ border-bottom: 1px dotted var(--line);
+ padding: 2px 0;
+}
+
+.kv dt {
+ color: var(--muted);
+}
+
+.kv dd {
+ margin: 0;
+ text-align: right;
}
.issues {
@@ -208,21 +368,218 @@ td:first-child {
.issues li {
margin-bottom: 6px;
- color: var(--text);
}
.issues .code {
- font-weight: 600;
+ font-weight: 700;
color: var(--warn);
}
-.issues .sheet-ok {
- font-weight: 600;
- color: var(--primary);
+.issues .code.E {
+ color: var(--error);
}
-@media (max-width: 900px) {
- .layout {
+details {
+ margin-top: 8px;
+}
+
+summary {
+ cursor: pointer;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+}
+
+/* --- import dialog (④) --------------------------------------------------- */
+
+#import-dialog {
+ width: min(1140px, 96vw);
+ max-height: 92vh;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ color: var(--text);
+}
+
+#import-dialog::backdrop {
+ background: rgba(20, 28, 38, 0.35);
+}
+
+.dlg-head {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+.dlg-head h2 {
+ margin: 0;
+}
+
+.dlg-steps {
+ display: flex;
+ gap: 8px;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ flex: 1;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.dlg-steps li {
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: #f0eee6;
+}
+
+.dlg-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 16px;
+ padding: 16px;
+ overflow: auto;
+ max-height: 70vh;
+}
+
+.dlg-col section {
+ margin-bottom: 16px;
+}
+
+.field {
+ display: grid;
+ gap: 4px;
+ margin-bottom: 8px;
+ font-size: 13px;
+}
+
+.field input {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 4px 8px;
+ background: var(--panel);
+ color: var(--text);
+}
+
+.drop {
+ border: 2px dashed var(--line);
+ border-radius: 8px;
+ padding: 16px;
+ text-align: center;
+}
+
+.drop.over {
+ border-color: var(--primary);
+}
+
+.drop p {
+ margin: 0 0 8px;
+}
+
+.drop-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.sheet-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.sheet-list > li {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 8px;
+ margin-bottom: 8px;
+}
+
+.sheet-list .ok {
+ color: var(--primary);
+ font-weight: 600;
+}
+
+.sheet-list .bad {
+ color: var(--error);
+ font-weight: 600;
+}
+
+.sheet-pier {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 44px;
+}
+
+.sheet-pier input {
+ width: 24px;
+ height: 24px;
+}
+
+.rules {
+ margin: 0;
+ padding-left: 18px;
+ font-size: 13px;
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: 13px;
+}
+
+.terms th {
+ text-align: left;
+ width: 40%;
+ color: var(--muted);
+ font-weight: 400;
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--line);
+}
+
+.terms td {
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--line);
+}
+
+.grid-table th,
+.grid-table td {
+ border-bottom: 1px solid var(--line);
+ padding: 4px 6px;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.grid-table th {
+ color: var(--muted);
+ font-weight: 400;
+}
+
+.dlg-foot {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 12px 16px;
+ border-top: 1px solid var(--line);
+ flex-wrap: wrap;
+}
+
+#dlg-selected {
+ margin-right: auto;
+}
+
+@media (max-width: 1100px) {
+ .grid {
+ grid-template-columns: 1fr;
+ }
+
+ .dlg-body {
grid-template-columns: 1fr;
}