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:
1 parent
01d2bf4958
commit
69922cfa35
3 files changed
+1081
-319
No files matched your search
+163
-45
@@ -3,55 +3,173 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>교각 3D 형상 생성 (1단계)</title>
|
||||
<title>Pierzainer — 형상 생성</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>교각 3D 형상 생성 <span class="stage">① 형상 생성 (1단계)</span></h1>
|
||||
<div class="actions">
|
||||
<button id="sample" class="primary" type="button">샘플 P1 불러오기</button>
|
||||
<label class="file">
|
||||
엑셀 선택
|
||||
<input id="file" type="file" accept=".xlsx" />
|
||||
</label>
|
||||
<div class="app">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<strong>교각 설계</strong>
|
||||
<span id="project-line" class="muted">프로젝트 미지정</span>
|
||||
</div>
|
||||
<nav class="steps" aria-label="단계">
|
||||
<span class="step current" aria-current="step">① 형상 생성 <em id="step1-status">미생성</em></span>
|
||||
<span class="step" aria-disabled="true">② 형상계획 <em>예정</em></span>
|
||||
<span class="step" aria-disabled="true">③ 철근배근 <em>예정</em></span>
|
||||
</nav>
|
||||
<div class="actions">
|
||||
<button id="open-import" class="btn" type="button">엑셀 불러오기</button>
|
||||
<button id="regen" class="btn primary" type="button" disabled>선택 교각 형상 생성</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p id="status" class="status-line" role="status" aria-live="polite">엑셀을 불러오면 교각 형상을 생성합니다.</p>
|
||||
|
||||
<main class="grid">
|
||||
<aside class="panel piers" aria-labelledby="pier-title">
|
||||
<h2 id="pier-title">교각 목록</h2>
|
||||
<div class="pier-tools">
|
||||
<label>형식
|
||||
<select id="type-filter"></select>
|
||||
</label>
|
||||
<button id="select-all" class="btn small" type="button">전체 선택</button>
|
||||
</div>
|
||||
<ul id="pier-items" class="pier-items"></ul>
|
||||
<p id="pier-empty" class="muted">불러온 교각이 없습니다.</p>
|
||||
</aside>
|
||||
|
||||
<section class="panel viewer" aria-labelledby="viewer-title">
|
||||
<div class="viewer-head">
|
||||
<h2 id="viewer-title">교각 3D 뷰</h2>
|
||||
<div id="views" class="views" role="group" aria-label="시점"></div>
|
||||
<span id="origin" class="muted"></span>
|
||||
</div>
|
||||
<div id="canvas" class="canvas" aria-label="교각 3D 뷰"></div>
|
||||
<fieldset class="parts">
|
||||
<legend>부재 표시</legend>
|
||||
<div id="parts"></div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<aside class="panel results" aria-labelledby="result-title">
|
||||
<h2 id="result-title">계산 결과</h2>
|
||||
<section aria-labelledby="sec-axis">
|
||||
<h3 id="sec-axis">교각축 · 코핑</h3>
|
||||
<dl id="res-axis" class="kv"></dl>
|
||||
</section>
|
||||
<section aria-labelledby="sec-column">
|
||||
<h3 id="sec-column">기둥</h3>
|
||||
<dl id="res-column" class="kv"></dl>
|
||||
</section>
|
||||
<section aria-labelledby="sec-found">
|
||||
<h3 id="sec-found">기초</h3>
|
||||
<dl id="res-found" class="kv"></dl>
|
||||
</section>
|
||||
<section aria-labelledby="sec-issues">
|
||||
<h3 id="sec-issues">알림 · 정보 <span id="issue-count" class="muted"></span></h3>
|
||||
<ul id="issues" class="issues"></ul>
|
||||
<p class="muted small">오류(E)는 생성 중단, 경고(W)는 생성 후 표시, 정보(I)는 참고용입니다.</p>
|
||||
</section>
|
||||
<details>
|
||||
<summary>기본값 · 미구현 규칙</summary>
|
||||
<ul id="defaults" class="issues"></ul>
|
||||
</details>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<section class="panel inputs" aria-labelledby="input-title">
|
||||
<h2 id="input-title">입력값</h2>
|
||||
<dl id="inputs" class="kv wide"></dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog id="import-dialog" aria-labelledby="dlg-title">
|
||||
<header class="dlg-head">
|
||||
<h2 id="dlg-title">엑셀 불러오기</h2>
|
||||
<ol class="dlg-steps">
|
||||
<li>1 파일 선택</li>
|
||||
<li>2 인식 결과 · 미리보기</li>
|
||||
<li>3 형상 생성</li>
|
||||
</ol>
|
||||
<button id="dlg-close" class="btn small" type="button">닫기</button>
|
||||
</header>
|
||||
|
||||
<div class="dlg-body">
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>프로젝트</h3>
|
||||
<label class="field">프로젝트명 · 필수
|
||||
<input id="project-name" type="text" required />
|
||||
</label>
|
||||
<label class="field">교량명
|
||||
<input id="bridge-name" type="text" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>파일</h3>
|
||||
<div id="drop" class="drop">
|
||||
<p>다른 .xlsx 파일을 끌어놓거나</p>
|
||||
<div class="drop-actions">
|
||||
<button id="pick-file" class="btn small" type="button">파일 선택</button>
|
||||
<button id="pick-sample" class="btn small" type="button">샘플 P1 사용</button>
|
||||
</div>
|
||||
<input id="file" type="file" accept=".xlsx" hidden />
|
||||
</div>
|
||||
<p id="file-name" class="muted small"></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>시트별 인식 결과</h3>
|
||||
<ul id="sheets" class="sheet-list"></ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>작성 규칙</h3>
|
||||
<ul class="rules">
|
||||
<li>교각 1기 = 시트 1장을 기본으로 하며, 한 시트에 여러 기를 넣어도 됩니다.</li>
|
||||
<li>표는 머리글 문자열로 찾으므로 위치는 자유입니다.</li>
|
||||
<li>수식 셀은 계산된 값을 읽습니다.</li>
|
||||
<li>방위각은 75°56'55.9" 형식으로 적습니다.</li>
|
||||
</ul>
|
||||
<h4>엑셀 열 이름 → 화면 용어</h4>
|
||||
<table class="terms">
|
||||
<tbody>
|
||||
<tr><th>두부보</th><td>코핑</td></tr>
|
||||
<tr><th>대좌블럭</th><td>받침블럭</td></tr>
|
||||
<tr><th>저판</th><td>확대기초</td></tr>
|
||||
<tr><th>받침</th><td>교량받침</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>미리보기</h3>
|
||||
<p id="preview-title" class="muted"></p>
|
||||
<h4>교각 정보</h4>
|
||||
<table id="pv-pier" class="grid-table"><tbody></tbody></table>
|
||||
<h4>받침</h4>
|
||||
<table id="pv-bearings" class="grid-table">
|
||||
<thead><tr><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<h4>IP</h4>
|
||||
<table id="pv-ips" class="grid-table">
|
||||
<thead><tr><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="side">
|
||||
<section>
|
||||
<h2>교각</h2>
|
||||
<label for="pier">교각 선택</label>
|
||||
<select id="pier" disabled></select>
|
||||
<p id="status" class="muted" role="status" aria-live="polite">엑셀을 불러오면 교각 형상을 생성합니다.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>표시 부재</h2>
|
||||
<div id="parts" class="parts"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>시점</h2>
|
||||
<div id="views" class="views"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="viewer">
|
||||
<div id="canvas" class="canvas" aria-label="교각 3D 뷰"></div>
|
||||
</section>
|
||||
|
||||
<section class="results">
|
||||
<h2>엑셀 시트 인식 결과</h2>
|
||||
<ul id="sheets" class="issues"></ul>
|
||||
<h2>계산 결과</h2>
|
||||
<table id="summary"><tbody></tbody></table>
|
||||
<h2>알림</h2>
|
||||
<ul id="issues" class="issues"></ul>
|
||||
<h2>기본값 적용 · 미구현</h2>
|
||||
<ul id="defaults" class="issues"></ul>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="dlg-foot">
|
||||
<span id="dlg-selected" class="muted"></span>
|
||||
<button id="dlg-cancel" class="btn" type="button">취소</button>
|
||||
<button id="dlg-apply" class="btn primary" type="button" disabled>선택한 교각 0기 형상 생성</button>
|
||||
</footer>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
+453
-166
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user