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>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-21 17:49:04 +09:00
1 parent 01d2bf4958
commit 69922cfa35
3 files changed
+1081 -319

No files matched your search

+453 -166
View File
@@ -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<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;
const VIEW_LABELS: Record<ViewKind, string> = { iso: '아이소', front: '정면', side: '측면', top: '평면' };
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;
}
const state: {
piers: Loaded[];
parseIssues: Issue[];
sheets: SheetReport[];
current: { loaded: Loaded; scene: PierScene } | null;
view: ViewKind;
visible: Record<PartName, boolean>;
} = {
piers: [],
parseIssues: [],
sheets: [],
current: null,
view: 'iso',
visible: Object.fromEntries(Object.keys(PART_LABELS).map((k) => [k, true])) as Record<PartName, boolean>,
};
function setStatus(msg: string): void {
$('status').textContent = msg;
}
// --- 3D renderer -----------------------------------------------------------
// --- 3D renderer -------------------------------------------------------------
const container = $<HTMLDivElement>('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<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;
// --- 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');
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 = $<HTMLDivElement>('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<HTMLButtonElement>('#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 = $<HTMLSelectElement>('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 = $<HTMLSelectElement>('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 = $<HTMLSelectElement>('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 = $<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 ? '선택 해제' : '전체 선택';
}
// --- 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 ? '생성됨' : '미생성';
$<HTMLButtonElement>('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 = $<HTMLTableElement>('summary').tBodies[0] ?? $<HTMLTableElement>('summary').createTBody();
body.replaceChildren();
const issuesBox = $<HTMLUListElement>('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 = $<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'), 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 = $<HTMLUListElement>('defaults');
defaults.replaceChildren();
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) defaults.appendChild(el('li', text));
for (const text of items) box.appendChild(el('li', text));
}
function renderSheets(): void {
const box = $<HTMLUListElement>('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 = $<HTMLUListElement>('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 = $<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);
}
}
// --- Events -----------------------------------------------------------------------
$<HTMLButtonElement>('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 = $<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;
$('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}`);
}
});
$<HTMLButtonElement>('pick-sample').addEventListener('click', async () => {
try {
loadBytes(await fetchSample(), '샘플 P1 (Pier ui_xxx.xlsx)');
} catch (err) {
setStatus(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
}
});
$<HTMLSelectElement>('pier').addEventListener('change', (ev) => showPier((ev.target as HTMLSelectElement).value));
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();
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') {
$<HTMLButtonElement>('sample').click();
fetchSample()
.then((bytes) => {
loadBytes(bytes, '샘플 P1');
$<HTMLInputElement>('project-name').value = '운곡교 P1 샘플';
$<HTMLInputElement>('bridge-name').value = '운곡교';
applyImport();
})
.catch((err: Error) => setStatus(`샘플을 불러오지 못했습니다: ${err.message}`));
}
+465 -108
View File
@@ -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;
}