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

+163 -45
View File
@@ -3,55 +3,173 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>교각 3D 형상 생성 (1단계)</title> <title>Pierzainer — 형상 생성</title>
</head> </head>
<body> <body>
<header class="top"> <div class="app">
<h1>교각 3D 형상 생성 <span class="stage">① 형상 생성 (1단계)</span></h1> <header class="top">
<div class="actions"> <div class="brand">
<button id="sample" class="primary" type="button">샘플 P1 불러오기</button> <strong>교각 설계</strong>
<label class="file"> <span id="project-line" class="muted">프로젝트 미지정</span>
엑셀 선택 </div>
<input id="file" type="file" accept=".xlsx" /> <nav class="steps" aria-label="단계">
</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> </div>
</header>
<main class="layout"> <footer class="dlg-foot">
<aside class="side"> <span id="dlg-selected" class="muted"></span>
<section> <button id="dlg-cancel" class="btn" type="button">취소</button>
<h2>교각</h2> <button id="dlg-apply" class="btn primary" type="button" disabled>선택한 교각 0기 형상 생성</button>
<label for="pier">교각 선택</label> </footer>
<select id="pier" disabled></select> </dialog>
<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>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
+453 -166
View File
@@ -5,36 +5,69 @@ import * as XLSX from 'xlsx';
import { parseWorkbook, type SheetReport } from './core/parseWorkbook'; import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
import { buildPier, type PierModel } from './core/geometry'; import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
import type { Issue, PierInput } from './core/types'; import type { Issue, PierInput, PierType } from './core/types';
import { buildPierScene, PART_LABELS, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; 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; input: PierInput;
model: PierModel | null; model: PierModel | null;
issues: Issue[]; 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 $ = <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: { function setStatus(msg: string): void {
piers: Loaded[]; $('status').textContent = msg;
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>,
};
// --- 3D renderer ----------------------------------------------------------- // --- 3D renderer -------------------------------------------------------------
const container = $<HTMLDivElement>('canvas'); const container = $<HTMLDivElement>('canvas');
const renderer = new THREE.WebGLRenderer({ antialias: true }); const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
@@ -52,6 +85,7 @@ world.add(sun);
function resize(): void { function resize(): void {
const w = container.clientWidth; const w = container.clientWidth;
const h = container.clientHeight; const h = container.clientHeight;
if (w === 0 || h === 0) return;
renderer.setSize(w, h); renderer.setSize(w, h);
camera.aspect = w / h; camera.aspect = w / h;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
@@ -66,37 +100,57 @@ function animate(): void {
} }
animate(); animate();
// --- UI builders (textContent only: workbook text is never parsed as HTML) ------- // --- pier model ----------------------------------------------------------------
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] { function buildEntry(input: PierInput, checked: boolean): Pier {
const node = document.createElement(tag); const issues: Issue[] = [];
if (text !== undefined) node.textContent = text; let model: PierModel | null = null;
if (cls) node.className = cls; if (validateInput(input, issues)) {
return node; 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 { function buildPartToggles(): void {
const box = $<HTMLDivElement>('parts'); 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 label = el('label');
const input = el('input'); const input = el('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.checked = state.visible[key]; input.checked = group.parts.every((p) => state.visible[p]);
input.addEventListener('change', () => { input.addEventListener('change', () => {
state.visible[key] = input.checked; for (const p of group.parts) {
if (state.current) state.current.scene.parts[key].visible = input.checked; state.visible[p] = input.checked;
if (state.scene) state.scene.parts[p].visible = input.checked;
}
}); });
label.append(input, document.createTextNode(PART_LABELS[key])); label.append(input, document.createTextNode(group.label));
box.appendChild(label); row.appendChild(label);
} }
box.appendChild(row);
} }
function buildViewButtons(): void { function buildViewButtons(): void {
const box = $<HTMLDivElement>('views'); const box = $<HTMLDivElement>('views');
for (const kind of Object.keys(VIEW_LABELS) as ViewKind[]) { for (const v of VIEWS) {
const btn = el('button', VIEW_LABELS[kind]); const btn = el('button', v.label, 'btn');
btn.type = 'button'; btn.type = 'button';
btn.setAttribute('aria-pressed', String(kind === state.view)); btn.setAttribute('aria-pressed', String(v.kind === state.view));
btn.addEventListener('click', () => setView(kind)); btn.addEventListener('click', () => setView(v.kind));
box.appendChild(btn); box.appendChild(btn);
} }
} }
@@ -104,188 +158,421 @@ function buildViewButtons(): void {
function setView(kind: ViewKind): void { function setView(kind: ViewKind): void {
state.view = kind; state.view = kind;
document.querySelectorAll<HTMLButtonElement>('#views button').forEach((b, i) => { 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; if (!state.scene) return;
const { position, target } = viewPosition(state.current.scene, kind, state.current.loaded.model!); const pier = activePier();
if (!pier?.model) return;
const { position, target } = viewPosition(state.scene, kind, pier.model);
camera.position.copy(position); camera.position.copy(position);
controls.target.copy(target); controls.target.copy(target);
controls.update(); controls.update();
} }
// --- Loading ---------------------------------------------------------------------- function setupScene(): void {
function loadWorkbookBytes(bytes: Uint8Array): void { if (state.scene) world.remove(state.scene.root);
const { piers, issues: parseIssues, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' })); state.scene = null;
const loaded: Loaded[] = piers.map((input) => { const pier = activePier();
const issues: Issue[] = []; if (pier?.model) {
let model: PierModel | null = null; const scene = buildPierScene(pier.model);
if (validateInput(input, issues)) { for (const key of Object.keys(state.visible) as PartName[]) {
try { scene.parts[key].visible = state.visible[key];
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 }; world.add(scene.root);
}); state.scene = scene;
state.piers = loaded; setView(state.view);
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));
} }
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 { // --- main list (시안 ① 교각 목록) ----------------------------------------------------
const select = $<HTMLSelectElement>('pier'); function renderPierList(): void {
if (state.current) world.remove(state.current.scene.root); const select = $<HTMLSelectElement>('type-filter');
state.current = null; if (select.options.length === 0) {
if (!name) { select.appendChild(new Option('전체', 'all'));
renderResults(null); for (const t of TYPES) select.appendChild(new Option(t, t));
return;
} }
const loaded = state.piers.find((l) => l.input.name === name && l.model); select.value = state.filter;
if (!loaded || !loaded.model) return; for (const opt of Array.from(select.options)) {
const scene = buildPierScene(loaded.model); const count = opt.value === 'all' ? state.piers.length : state.piers.filter((p) => p.input.type === opt.value).length;
for (const key of Object.keys(PART_LABELS) as PartName[]) { opt.textContent = `${opt.value === 'all' ? '전체' : opt.value} (${count})`;
scene.parts[key].visible = state.visible[key];
} }
world.add(scene.root);
state.current = { loaded, scene }; const list = $<HTMLUListElement>('pier-items');
select.value = name; list.replaceChildren();
setView(state.view); const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
renderResults(loaded); $<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 renderHeader(): void {
function row(table: HTMLTableSectionElement, label: string, value: string): void { const pier = activePier();
const tr = el('tr'); const parts = [state.project.name || '프로젝트 미지정'];
tr.append(el('td', label), el('td', value)); if (state.project.bridge) parts.push(state.project.bridge);
table.appendChild(tr); 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)}`; const pt = (p: { e: number; n: number }) => `E ${p.e.toFixed(3)}, N ${p.n.toFixed(3)}`;
function renderResults(loaded: Loaded | null): void { function renderPanels(): void {
const body = $<HTMLTableElement>('summary').tBodies[0] ?? $<HTMLTableElement>('summary').createTBody(); const pier = activePier();
body.replaceChildren(); const m = pier?.model;
const issuesBox = $<HTMLUListElement>('issues'); $('viewer-title').textContent = pier ? `${pier.input.name} · ${pier.input.type} 교각 형상` : '교각 3D 뷰';
issuesBox.replaceChildren(); $('origin').textContent = m ? `원점 = 교각 중심 (${pt(m.pivot)})` : '';
const m = loaded?.model; if (!pier) {
if (m && loaded) { fillKv($('inputs'), []);
row(body, '교각', loaded.input.name); fillKv($('res-axis'), []);
row(body, '교각축 길이 L_IP (m)', fmt(m.lengthIp)); fillKv($('res-column'), []);
row(body, '교각축 방위각 (°)', fmt(m.azimuthDeg, 2)); fillKv($('res-found'), []);
row(body, 'BP1 (국토좌표 m)', pt(m.bp1)); $('issues').replaceChildren(el('li', '교각을 불러오면 알림이 표시됩니다.'));
row(body, 'BP2 (국토좌표 m)', pt(m.bp2)); $('issue-count').textContent = '';
row(body, 'd_IP2 (m)', fmt(m.dIp2)); return;
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(' / ')); const inp = pier.input;
row(body, '계단 경계 (BP1 기준, m)', m.segments.slice(1).map((s) => fmt(s.s0)).join(' / ')); const front = inp.bearings.filter((b) => b.row === '전열').length;
row(body, '횡방향 단차', '없음'); const back = inp.bearings.filter((b) => b.row === '후열').length;
row(body, '코핑 끝단 하면 EL (m)', fmt(m.endBottom)); const ip2 = inp.ips.find((p) => p.name === 'IP4');
row(body, '코핑 Level 하면 EL (m)', fmt(m.levelBottom)); fillKv($('inputs'), [
row(body, '기둥 높이 (m)', fmt(m.derived.columnHeight)); ['교각 / 기초 형식', `${inp.type} · ${inp.foundationType}`],
row(body, '확대기초 상면 EL (m)', m.footings.length ? fmt(m.footings[0].top) : '없음'); ['코핑 상면 EL', `${f3(inp.copingTopEl)} m`],
row(body, '흙덮개 (m)', fmt(m.derived.soilCover)); ['기초 하단 EL', `${f3(inp.footingBottomEl)} m`],
row(body, '말뚝 수 (본)', String(m.derived.pileCount)); ['지반고 EL', `${f3(inp.groundEl)} m`],
row(body, '말뚝 배치', m.derived.pileLayout ?? '없음'); ['코핑 폭 × 길이', `${inp.copingWidthMm} × ${inp.copingLengthMm} mm`],
row(body, '말뚝 선단 EL (m)', m.derived.pileTipEl === null ? '없음' : fmt(m.derived.pileTipEl)); ['말뚝 길이', `${inp.pileLengthMm} mm`],
row(body, '받침블럭 콘크리트 전열 / 후열 (mm)', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)}`); ['IP', ip2 && ip2.angleDeg === 0 ? 'IP1–IP3 직선 (꺾임 0°)' : '꺾임 코핑'],
['받침', `전열 ${front} · 후열 ${back}`],
]);
if (!m) {
fillKv($('res-axis'), [['상태', '생성 불가 (알림 참조)']]);
fillKv($('res-column'), []);
fillKv($('res-found'), []);
} else { } 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)]; const list = $<HTMLUListElement>('issues');
if (allIssues.length === 0) { list.replaceChildren();
issuesBox.appendChild(el('li', '알림 없음')); if (pier.issues.length === 0) list.appendChild(el('li', '알림 없음'));
} for (const issue of pier.issues) {
for (const issue of allIssues) {
const li = el('li'); const li = el('li');
li.append(el('span', issue.code, 'code'), document.createTextNode(` ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`)); li.append(el('span', issue.code, `code ${issue.level}`), document.createTextNode(` ${issue.message}`));
issuesBox.appendChild(li); list.appendChild(li);
} }
$('issue-count').textContent = `(${pier.issues.length}건)`;
}
const defaults = $<HTMLUListElement>('defaults'); function renderDefaults(): void {
defaults.replaceChildren(); const box = $<HTMLUListElement>('defaults');
box.replaceChildren();
const items = [ const items = [
'[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용', '[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용',
'[미확정] Q5 말뚝 지름 600 mm (강관) 기본값', '[미확정] Q5 말뚝 지름 600 mm (강관) 기본값',
'[미확정] Q4 직접기초 버림콘크리트 적용', '[미확정] Q4 직접기초 버림콘크리트 적용',
'횡방향 단차: 없을 때만 생성 (있으면 미실시 알림)',
...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`), ...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 { function renderAll(): void {
const box = $<HTMLUListElement>('sheets'); renderHeader();
box.replaceChildren(); renderPierList();
if (state.sheets.length === 0) { renderPanels();
box.appendChild(el('li', '엑셀을 불러오면 시트별 인식 결과가 표시됩니다.')); }
// --- 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; return;
} }
for (const s of state.sheets) {
for (const sheet of p.sheets) {
const li = el('li'); const li = el('li');
const status = s.ok ? '선택 가능' : '선택 불가 (오류)'; const head = el('div', undefined);
const head = el('span', `${s.name} · ${status}`, s.ok ? 'sheet-ok' : 'code'); head.append(
const detail = document.createTextNode(` · 교각 ${s.tables.pier}표 · IP ${s.tables.ip}표 · 받침 ${s.tables.bearing}표`); el('span', `${sheet.name} · ${sheet.ok ? '선택 가능' : '선택 불가 (오류)'}`, sheet.ok ? 'ok' : 'bad'),
li.append(head, detail); document.createTextNode(` · 교각 ${sheet.tables.pier}표 · IP ${sheet.tables.ip}표 · 받침 ${sheet.tables.bearing}표`),
box.appendChild(li); );
for (const issue of s.issues) { li.appendChild(head);
const sub = el('li', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, issue.level === 'E' ? 'code' : undefined); for (const issue of sheet.issues) {
sub.style.marginLeft = '16px'; const line = el('div', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, 'small');
box.appendChild(sub); 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 ----------------------------------------------------------------------- function updateApply(): void {
$<HTMLButtonElement>('sample').addEventListener('click', async () => { const p = state.pending;
$('status').textContent = '샘플 불러오는 중…'; const count = p ? p.entries.filter((e) => e.checked && selectable(p, e)).length : 0;
try { const total = p ? p.entries.filter((e) => selectable(p, e)).length : 0;
const res = await fetch(new URL('samples/P1.xlsx', document.baseURI)); $('dlg-selected').textContent = `선택 ${count} / ${total}기`;
loadWorkbookBytes(new Uint8Array(await res.arrayBuffer())); const named = $<HTMLInputElement>('project-name').value.trim().length > 0;
} catch (err) { const apply = $<HTMLButtonElement>('dlg-apply');
$('status').textContent = `샘플을 불러오지 못했습니다: ${(err as Error).message}`; 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) => { $<HTMLInputElement>('file').addEventListener('change', async (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0]; const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return; if (!file) return;
$('status').textContent = `${file.name} 읽는 중…`;
try { try {
loadWorkbookBytes(new Uint8Array(await file.arrayBuffer())); loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
} catch (err) { } 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(); buildPartToggles();
buildViewButtons(); buildViewButtons();
renderSheets(); renderDefaults();
renderResults(null); 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') { 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 { :root {
--bg: #f5f7fa; --bg: #faf9f5;
--panel: #ffffff; --panel: #ffffff;
--text: #1d2733; --text: #1d2733;
--muted: #5b6878; --muted: #5b6878;
--line: #d5dce4; --line: #d9d6cc;
--primary: #1f5fa8; --primary: #1f5fa8;
--warn: #9a5b00; --dim: #4a5868;
--warn: #8a5300;
--error: #9b2c2c;
--focus: 2px solid #1f5fa8; --focus: 2px solid #1f5fa8;
color-scheme: light dark; color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #111820;
--panel: #1a232d;
--text: #e6ebf0;
--muted: #9aa7b4;
--line: #2d3a47;
--primary: #5b93d6;
--warn: #e0a24a;
}
} }
* { * {
@@ -33,172 +24,341 @@ body {
font: 14px/1.5 system-ui, 'Noto Sans KR', sans-serif; font: 14px/1.5 system-ui, 'Noto Sans KR', sans-serif;
} }
button, h2 {
select, font-size: 16px;
input, margin: 0 0 10px;
label { }
font: inherit;
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, button,
select, select,
.file { input {
font: inherit;
min-height: 44px; min-height: 44px;
} }
button:focus-visible, button:focus-visible,
select:focus-visible, select:focus-visible,
input:focus-visible { input:focus-visible,
summary:focus-visible {
outline: var(--focus); outline: var(--focus);
outline-offset: 2px; 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 { .top {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 16px; padding: 10px 16px;
border-bottom: 1px solid var(--line);
background: var(--panel); background: var(--panel);
border-bottom: 1px solid var(--line);
} }
.top h1 { .brand {
font-size: 18px; display: flex;
margin: 0; gap: 10px;
align-items: baseline;
} }
.stage { .brand strong {
font-size: 13px; 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); color: var(--muted);
}
.step.current {
border-color: var(--primary);
color: var(--text);
font-weight: 600;
}
.step em {
font-style: normal;
font-weight: 400; font-weight: 400;
margin-left: 8px; color: var(--muted);
margin-left: 4px;
} }
.actions { .actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
.primary { .status-line {
background: var(--primary); margin: 0;
color: #fff; padding: 8px 16px;
border: 0; color: var(--muted);
border-radius: 6px; font-size: 13px;
padding: 0 14px;
cursor: pointer;
} }
.file { /* --- layout ------------------------------------------------------------- */
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 6px;
cursor: pointer;
}
.file input { .grid {
font-size: 12px;
max-width: 220px;
}
.layout {
display: grid; display: grid;
grid-template-columns: 240px 1fr 320px; grid-template-columns: 260px minmax(0, 1fr) 320px;
gap: 12px; gap: 12px;
padding: 12px 16px; padding: 0 16px 12px;
min-height: calc(100vh - 80px); flex: 1;
} }
.side, .panel {
.results,
.viewer {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
min-width: 0;
} }
.side section, .inputs {
.results section { margin: 0 16px 16px;
margin-bottom: 16px;
} }
h2 { /* --- pier list ---------------------------------------------------------- */
font-size: 15px;
margin: 0 0 8px; .pier-tools {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
} }
.muted { .pier-tools label {
color: var(--muted); display: flex;
gap: 6px;
align-items: center;
} }
select { select {
width: 100%; padding: 4px 8px;
padding: 6px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
color: var(--text); color: var(--text);
} }
.parts label, .pier-items {
.views button { list-style: none;
display: flex; padding: 0;
margin: 0;
max-height: 70vh;
overflow: auto;
}
.pier-items li {
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
min-height: 44px; 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 { .views {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; gap: 6px;
gap: 8px; flex-wrap: wrap;
} }
.views button { .views .btn {
border: 1px solid var(--line); min-width: 64px;
border-radius: 6px;
background: var(--panel);
color: var(--text);
justify-content: center;
cursor: pointer;
} }
.views button[aria-pressed='true'] { #origin {
border-color: var(--primary); margin-left: auto;
color: var(--primary); font-size: 12px;
font-weight: 600;
} }
.canvas { .canvas {
width: 100%; width: 100%;
height: calc(100vh - 140px); height: 62vh;
min-height: 420px; min-height: 420px;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: #e9eef4; background: #e9eef4;
} }
table { .parts {
width: 100%; border: 1px solid var(--line);
border-collapse: collapse; border-radius: 6px;
margin: 10px 0 0;
padding: 6px 10px;
} }
td { .parts legend {
padding: 4px 6px;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
td:first-child {
color: var(--muted); 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 { .issues {
@@ -208,21 +368,218 @@ td:first-child {
.issues li { .issues li {
margin-bottom: 6px; margin-bottom: 6px;
color: var(--text);
} }
.issues .code { .issues .code {
font-weight: 600; font-weight: 700;
color: var(--warn); color: var(--warn);
} }
.issues .sheet-ok { .issues .code.E {
font-weight: 600; color: var(--error);
color: var(--primary);
} }
@media (max-width: 900px) { details {
.layout { 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; grid-template-columns: 1fr;
} }