- 기둥 패널의 단면 세 그림을 flex 한 행(nowrap)으로 두고, 좁은 화면에서는 가로 스크롤 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
3002 lines
137 KiB
TypeScript
3002 lines
137 KiB
TypeScript
import './style.css';
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||
import * as XLSX from 'xlsx';
|
||
import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
|
||
import { buildPier, type PierModel } from './core/geometry';
|
||
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
|
||
import { buildTemplateWorkbook } from './core/template';
|
||
import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
|
||
import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles';
|
||
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
|
||
import { pileGeometry } from './core/geometry';
|
||
import { rebarSummary, type RebarRow } from './core/rebar/summary';
|
||
import { shearSegments } from './core/rebar/coping';
|
||
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
|
||
import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
|
||
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
|
||
import { parseDrawingRows } from './core/drawing-import';
|
||
import { groundElFor, parseGroundModel, strataRowsFor, type GroundModelFile } from './core/ground-model';
|
||
import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
|
||
import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout';
|
||
import { bearingPlanSvg, type BearingPlanOverlay, PAIR_CHIP_LABELS } from './view/bearing-plan-svg';
|
||
import { bearingSectionSvg, type BearingDrawingRef } from './view/bearing-section-svg';
|
||
import { solplateDetailSvg, type SolplateDrawingRef } from './view/solplate-svg';
|
||
import { blockRebarSectionSvg } from './view/block-rebar-svg';
|
||
import { blockRebarPlanSvg } from './view/block-rebar-plan-svg';
|
||
import { copingEndStation, copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg';
|
||
import { BLOCK_REBAR_RANGES } from './core/rebar/block-check';
|
||
import { DEFAULT_BLOCK_REBAR, type BlockRebarSettings } from './core/rebar/bearing-block';
|
||
import { COPING_REBAR_RANGES, copingSettingsIssues, type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './core/rebar/coping-settings';
|
||
import { COLUMN_REBAR_RANGES, type ColumnRebarSettings, columnSettingsIssues, DEFAULT_COLUMN_REBAR } from './core/rebar/column-settings';
|
||
import { columnInputFor, columnRebar } from './core/rebar/column';
|
||
import { columnElevationSvg } from './view/column-elevation-svg';
|
||
import { type ColumnSection, columnSectionSvg } from './view/column-section-svg';
|
||
import { groundSectionSvg } from './view/ground-svg';
|
||
import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table';
|
||
import { columnDiameterIssues } from './core/column-check';
|
||
import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec';
|
||
import { footingPlanSvg, footingSideSvg } from './view/footing-svg';
|
||
import { P1_COPING_GEOM } from './core/coping-geom';
|
||
import { applyBearingRules, BEARING_ELEVATION_REF, BEARING_RULES_DEFAULT, type BearingBasis, type BearingRules, type DrawingBearing, DRAWING_TOL_MM, drawingCopingCheck, SOLPLATE_REF, SOLPLATE_T_RULE, solplateTs, withDrawingBasis } from './core/bearing-ref';
|
||
|
||
/** 공통 규칙 of the bearing card (2단계). Screen state for now; not yet saved to the project file. */
|
||
let bearingRules: BearingRules = { ...BEARING_RULES_DEFAULT };
|
||
|
||
/** 받침 기준 for the project (Q16, 승인 2026-10-23): 'excel' by default. Drawing values per pier name. */
|
||
let bearingBasis: BearingBasis = 'excel';
|
||
let bearingDrawing: Record<string, DrawingBearing[]> = {};
|
||
|
||
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
|
||
let groundModel: GroundModelFile | null = null;
|
||
import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection';
|
||
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
|
||
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
|
||
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
|
||
import type { Issue, PierInput, PierType, StrataRow } from './core/types';
|
||
import { buildPierScene, setRebarView, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
||
|
||
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
|
||
interface Pier extends PierValues {
|
||
model: PierModel | null;
|
||
issues: Issue[];
|
||
checked: boolean;
|
||
}
|
||
|
||
/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */
|
||
/** The four stages: ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 재료표 (결과 화면). */
|
||
type Stage = 'shape' | 'plan' | 'rebar' | 'quantity';
|
||
|
||
const PART_GROUPS: { label: string; parts: PartName[] }[] = [
|
||
{ label: '받침·받침블럭', parts: ['blocks', 'pads', 'bearings'] },
|
||
{ label: '솔플레이트', parts: ['solplates'] },
|
||
{ 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주형'];
|
||
const CRS = '국토좌표계 (x = 동, y = 북)';
|
||
|
||
/** 시안 ② 부재 목록 (spec §5.2). `keys` are the inputs that exist today; the rest show values only. */
|
||
const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string }[] = [
|
||
{ id: 'bearing', label: '교량받침·받침블럭·솔플레이트', keys: ['bearingType', 'bearingLayout'], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' },
|
||
{ id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' },
|
||
{ id: 'column', label: '기둥', keys: ['columnDiameterMm'], note: '기둥 직경 D를 입력합니다 (1200–3000 mm, 50 mm 단위). 높이와 위치는 계산값입니다.' },
|
||
{ id: 'footing', label: '확대기초 · 말뚝', keys: ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileLengthMm'], note: '확대기초 치수, 비림, 말뚝 길이와 규격을 입력합니다. 규칙 미정 항목(모따기, 경사, 복합 말뚝)은 다음 단계에서 추가합니다.' },
|
||
{ id: 'ground', label: '지반·지층', keys: ['groundEl', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'], note: '지반고는 계산(흙덮개)에 쓰고, 지층은 행으로, 지하수위는 직접 입력합니다. 위치는 육상·하상·해상 중 고릅니다.' },
|
||
];
|
||
|
||
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: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo,
|
||
/** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */
|
||
stage: 'shape' as Stage,
|
||
/** ③ 공통 설정 (fck · fy). */
|
||
material: { ...DEFAULT_MATERIAL } as Material,
|
||
/** ② 부재별 fck. A member without an entry uses the 공통 fck (③). */
|
||
fckByMember: {} as FckByMember,
|
||
/** Bar highlighted on ③ after [보기] (시안 ③ 알림). */
|
||
/** Bars selected on ③ (철근표 checkboxes, 3D click, [보기]). */
|
||
rebarSelection: new Set<string>(),
|
||
/** 선택만 보기: the bars not selected are hidden in 3D. */
|
||
rebarIsolate: false,
|
||
/** Colour basis of the 3D rebar on ③: role (default), diameter, or member. */
|
||
rebarColor: 'role' as RebarColorMode,
|
||
/** Member tab on ③ (철근표). */
|
||
rebarMember: 'coping' as RebarRow['member'],
|
||
/** Member card selected on ② (시안 ② 부재 목록). */
|
||
member: 'coping' as MemberId,
|
||
/** True once the project has a file on disk (first save via 시안 ⑤, or an opened file). Later saves go straight to it. */
|
||
savedOnce: false,
|
||
filter: 'all' as 'all' | PierType,
|
||
pending: null as Pending | null,
|
||
source: { fileName: '', sheets: [] as string[] },
|
||
history: [] as ProjectFile['history'],
|
||
/** Snapshot of the inputs at the last save or open. null until the project is first saved. */
|
||
savedSnapshot: null as string | null,
|
||
};
|
||
|
||
let pendingBackup: Backup | null = null;
|
||
/** 3D rebar of the current pier (③); its bars are named as in the 철근표. */
|
||
let rebar3d: RebarScene | null = null;
|
||
let backupTimer: number | undefined;
|
||
|
||
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||
|
||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (cls) node.className = cls;
|
||
return node;
|
||
}
|
||
|
||
/** Results and short notices: a toast that goes away after 4 s. */
|
||
function setStatus(msg: string): void {
|
||
showToast(msg, false);
|
||
}
|
||
|
||
/** Problems: a toast that stays until the user closes it. */
|
||
function setError(msg: string): void {
|
||
showToast(msg, true);
|
||
}
|
||
|
||
function showToast(msg: string, sticky: boolean): void {
|
||
const host = $<HTMLDivElement>('toast-host');
|
||
const t = el('div', undefined, sticky ? 'toast error' : 'toast');
|
||
t.appendChild(el('span', msg));
|
||
if (sticky) {
|
||
const close = el('button', '닫기', 'btn small');
|
||
close.type = 'button';
|
||
close.addEventListener('click', () => t.remove());
|
||
t.appendChild(close);
|
||
} else {
|
||
window.setTimeout(() => t.remove(), 4000);
|
||
}
|
||
host.appendChild(t);
|
||
while (host.childElementCount > 3) host.firstElementChild?.remove();
|
||
}
|
||
|
||
// --- 3D renderer -------------------------------------------------------------
|
||
const container = $<HTMLDivElement>('canvas');
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.localClippingEnabled = true; // mitre planes on the bent coping
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
container.appendChild(renderer.domElement);
|
||
const world = new THREE.Scene();
|
||
world.background = new THREE.Color(0xe9eef4);
|
||
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 5000);
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
world.add(new THREE.HemisphereLight(0xffffff, 0x8899aa, 1.6));
|
||
const sun = new THREE.DirectionalLight(0xffffff, 1.2);
|
||
sun.position.set(30, 60, 40);
|
||
world.add(sun);
|
||
|
||
function resize(): void {
|
||
const w = container.clientWidth;
|
||
const h = container.clientHeight;
|
||
if (w === 0 || h === 0) return;
|
||
renderer.setSize(w, h);
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
// The 3D view follows its container, which changes when the layout frame or a panel size changes (승인 2026-10-23).
|
||
new ResizeObserver(() => resize()).observe(container);
|
||
resize();
|
||
|
||
function animate(): void {
|
||
controls.update();
|
||
renderer.render(world, camera);
|
||
requestAnimationFrame(animate);
|
||
}
|
||
animate();
|
||
|
||
// --- pier model ----------------------------------------------------------------
|
||
/** Fills the coping heights and Level width with the P1 values when a file or sheet does not give them (1단계). */
|
||
function withCopingDefaults(input: PierInput): PierInput {
|
||
return {
|
||
...input,
|
||
columnDiameterMm: input.columnDiameterMm ?? 2000,
|
||
footingLengthMm: input.footingLengthMm ?? 10400,
|
||
footingWidthMm: input.footingWidthMm ?? 8150,
|
||
footingHeightMm: input.footingHeightMm ?? 2000,
|
||
blindingMarginMm: input.blindingMarginMm ?? 100,
|
||
blindingThicknessMm: input.blindingThicknessMm ?? 100,
|
||
pileKind: input.pileKind ?? '강관',
|
||
pileOuterMm: input.pileOuterMm ?? 609.6,
|
||
pileThicknessMm: input.pileThicknessMm ?? 12,
|
||
copingH1Mm: input.copingH1Mm ?? P1_COPING_GEOM.h1,
|
||
copingH2Mm: input.copingH2Mm ?? P1_COPING_GEOM.h2,
|
||
levelWidthMm: input.levelWidthMm ?? P1_COPING_GEOM.levelWidth,
|
||
};
|
||
}
|
||
|
||
function buildEntry(rawInput: PierInput, checked: boolean): Pier {
|
||
const input = withCopingDefaults(rawInput);
|
||
const issues: Issue[] = [];
|
||
let model: PierModel | null = null;
|
||
// The model uses the drawing EL.X when the project basis is 'drawing' and this pier has drawing values (Q16).
|
||
const drawing = bearingBasis === 'drawing' ? bearingDrawing[input.name] : undefined;
|
||
const baseInput = drawing?.length ? withDrawingBasis(input, drawing) : input;
|
||
// Solplate plans come from the drawing rows that have A, B and S, whatever the 받침 기준 (승인 2026-10-23).
|
||
const plans = (bearingDrawing[input.name] ?? [])
|
||
.filter((d) => d.A !== undefined && d.B !== undefined && d.S !== undefined)
|
||
.map((d) => ({ no: d.no, A: d.A as number, B: d.B as number, S: d.S as number }));
|
||
const modelInput: PierInput = { ...baseInput, bearingPlans: plans, solplate: { T: bearingRules.T, margin: bearingRules.margin, h3: bearingRules.h3 } };
|
||
if (validateInput(input, issues)) {
|
||
try {
|
||
model = buildPier(modelInput);
|
||
validatePier(input, model, issues);
|
||
for (const message of columnDiameterIssues(modelInput.columnDiameterMm ?? 2000, model)) {
|
||
issues.push({ code: 'D01', level: 'E', pier: input.name, message });
|
||
}
|
||
} catch (err) {
|
||
issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message });
|
||
}
|
||
}
|
||
return { input, model, issues, checked, defaults: input, draft: input };
|
||
}
|
||
|
||
/** Builds the model from the draft (the cards' values) and keeps the imported defaults. */
|
||
function regenerateEntry(p: Pier): Pier {
|
||
return { ...buildEntry(p.draft, p.checked), defaults: p.defaults, draft: p.draft };
|
||
}
|
||
|
||
function sheetOk(sheets: SheetReport[], name: string): boolean {
|
||
return sheets.find((s) => s.name === name)?.ok === true;
|
||
}
|
||
|
||
function activePier(): Pier | undefined {
|
||
return state.piers.find((p) => p.input.name === state.active);
|
||
}
|
||
|
||
// --- parts and views -------------------------------------------------------------
|
||
function buildPartToggles(): void {
|
||
const box = $<HTMLDivElement>('parts');
|
||
const row = el('div', undefined, 'parts-row');
|
||
for (const group of PART_GROUPS) {
|
||
const label = el('label');
|
||
const input = el('input');
|
||
input.type = 'checkbox';
|
||
input.checked = group.parts.every((p) => state.visible[p]);
|
||
input.addEventListener('change', () => {
|
||
for (const p of group.parts) {
|
||
state.visible[p] = input.checked;
|
||
if (state.scene) state.scene.parts[p].visible = input.checked;
|
||
}
|
||
});
|
||
label.append(input, document.createTextNode(group.label));
|
||
row.appendChild(label);
|
||
}
|
||
box.appendChild(row);
|
||
}
|
||
|
||
function buildViewButtons(): void {
|
||
const box = $<HTMLDivElement>('views');
|
||
for (const v of VIEWS) {
|
||
const btn = el('button', v.label, 'btn');
|
||
btn.type = 'button';
|
||
btn.setAttribute('aria-pressed', String(v.kind === state.view));
|
||
btn.addEventListener('click', () => setView(v.kind));
|
||
box.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
function setView(kind: ViewKind): void {
|
||
state.view = kind;
|
||
document.querySelectorAll<HTMLButtonElement>('#views button').forEach((b, i) => {
|
||
b.setAttribute('aria-pressed', String(VIEWS[i].kind === kind));
|
||
});
|
||
// The band shows the current view only; choosing one closes the menu (승인 2026-10-23).
|
||
$('views-toggle').textContent = `${VIEWS.find((v) => v.kind === kind)?.label ?? ''} ▾`;
|
||
setMenuOpen('views', false);
|
||
if (!state.scene) return;
|
||
const pier = activePier();
|
||
if (!pier?.model) return;
|
||
const { position, target } = viewPosition(state.scene, kind, pier.model);
|
||
camera.position.copy(position);
|
||
controls.target.copy(target);
|
||
controls.update();
|
||
}
|
||
|
||
function setupScene(): void {
|
||
if (state.scene) world.remove(state.scene.root);
|
||
state.scene = null;
|
||
if (rebar3d) world.remove(rebar3d.group);
|
||
rebar3d = null;
|
||
const pier = activePier();
|
||
if (pier?.model) {
|
||
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
|
||
rebar3d.group.visible = state.stage === 'rebar';
|
||
rebar3d.setColorMode(state.rebarColor);
|
||
world.add(rebar3d.group);
|
||
const scene = buildPierScene(pier.model, groundModel?.terrainGrid);
|
||
for (const key of Object.keys(state.visible) as PartName[]) {
|
||
scene.parts[key].visible = state.visible[key];
|
||
}
|
||
world.add(scene.root);
|
||
setRebarView(scene, state.stage === 'rebar');
|
||
state.scene = scene;
|
||
setView(state.view);
|
||
}
|
||
}
|
||
|
||
// --- main list (시안 ① 교각 목록) ----------------------------------------------------
|
||
function renderPierList(): void {
|
||
const select = $<HTMLSelectElement>('type-filter');
|
||
if (select.options.length === 0) {
|
||
select.appendChild(new Option('전체', 'all'));
|
||
for (const t of TYPES) select.appendChild(new Option(t, t));
|
||
}
|
||
select.value = state.filter;
|
||
for (const opt of Array.from(select.options)) {
|
||
const count = opt.value === 'all' ? state.piers.length : state.piers.filter((p) => p.input.type === opt.value).length;
|
||
opt.textContent = `${opt.value === 'all' ? '전체' : opt.value} (${count})`;
|
||
}
|
||
|
||
renderPierRows($<HTMLUListElement>('pier-items'), 'shape');
|
||
renderPierRows($<HTMLUListElement>('plan-pier-items'), 'plan');
|
||
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
|
||
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
|
||
|
||
$<HTMLButtonElement>('select-all').textContent = allVisibleChecked(state.piers, state.filter) ? '선택 해제' : '전체 선택';
|
||
}
|
||
|
||
/** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */
|
||
function renderPierRows(list: HTMLUListElement, mode: Stage): void {
|
||
list.replaceChildren();
|
||
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
|
||
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();
|
||
});
|
||
// The pier name and type are shown here, since the 3D title band is gone on wide screens (승인 2026-10-23).
|
||
const name = el('button', `${pier.input.name} · ${pier.input.type}`, 'pier-name');
|
||
name.type = 'button';
|
||
name.setAttribute('aria-pressed', String(pier.input.name === state.active));
|
||
name.addEventListener('click', () => {
|
||
state.active = pier.input.name;
|
||
state.rebarSelection = new Set();
|
||
setupScene();
|
||
renderAll();
|
||
});
|
||
const badge = mode === 'plan'
|
||
? el('span', `변경 ${overrideKeys(pier).length}`, 'badge')
|
||
: mode === 'rebar' || mode === 'quantity'
|
||
? (pier.model ? el('span', '계산됨', 'badge') : el('span', '오류', 'badge error'))
|
||
: 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);
|
||
}
|
||
}
|
||
|
||
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 ? '생성됨' : '미생성';
|
||
renderSaveStatus();
|
||
const total = pendingTotal();
|
||
const checked = state.piers.filter((p) => p.checked).length;
|
||
$('step2-status').textContent = total ? `변경 ${total}건 · 재생성 필요` : '예정';
|
||
const regen = $<HTMLButtonElement>('regen');
|
||
regen.disabled = checked === 0;
|
||
regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성';
|
||
$('bulk-count').textContent = `선택 ${checked}기`;
|
||
$<HTMLButtonElement>('bulk-regen').disabled = checked === 0;
|
||
$<HTMLButtonElement>('bulk-copy').disabled = !activePier();
|
||
}
|
||
|
||
// --- results panel (시안 ① 입력값 · 계산 결과) ----------------------------------------
|
||
type Pair = [string, string];
|
||
|
||
function fillKv(dl: HTMLElement, pairs: Pair[]): void {
|
||
dl.replaceChildren();
|
||
for (const [k, v] of pairs) {
|
||
const row = el('div');
|
||
row.append(el('dt', k), el('dd', v));
|
||
dl.appendChild(row);
|
||
}
|
||
}
|
||
|
||
const f3 = (v: number) => v.toFixed(3);
|
||
const pt = (p: { e: number; n: number }) => `E ${p.e.toFixed(3)}, N ${p.n.toFixed(3)}`;
|
||
|
||
function renderPanels(): void {
|
||
const pier = activePier();
|
||
const m = pier?.model;
|
||
// The pier name is in 교각 목록 on wide screens; the title keeps it on narrow ones (승인 2026-10-23).
|
||
$('viewer-title').textContent = pier ? `${pier.input.name} · ${pier.input.type} 교각 형상` : '교각 3D 뷰';
|
||
$('origin').textContent = m ? `원점 = 교각 중심 (${pt(m.pivot)})` : '';
|
||
|
||
if (!pier) {
|
||
fillKv($('inputs'), []);
|
||
fillKv($('res-axis'), []);
|
||
fillKv($('res-column'), []);
|
||
fillKv($('res-found'), []);
|
||
$('issues').replaceChildren(el('li', '교각을 불러오면 알림이 표시됩니다.'));
|
||
$('issue-count').textContent = '';
|
||
return;
|
||
}
|
||
|
||
const inp = pier.input;
|
||
const front = inp.bearings.filter((b) => b.row === '전열').length;
|
||
const back = inp.bearings.filter((b) => b.row === '후열').length;
|
||
const ip2 = inp.ips.find((p) => p.name === 'IP4');
|
||
fillKv($('inputs'), [
|
||
['교각 / 기초 형식', `${inp.type} · ${inp.foundationType}`],
|
||
['코핑 상면 EL', `${f3(inp.copingTopEl)} m`],
|
||
['기초 하단 EL', `${f3(inp.footingBottomEl)} m`],
|
||
['지반고 EL', `${f3(inp.groundEl)} m`],
|
||
['코핑 폭 × 길이', `${inp.copingWidthMm} × ${inp.copingLengthMm} mm`],
|
||
['말뚝 길이', `${inp.pileLengthMm} mm`],
|
||
['IP', ip2 && ip2.angleDeg === 0 ? 'IP1–IP3 직선 (꺾임 0°)' : '꺾임 코핑'],
|
||
['받침', `전열 ${front} · 후열 ${back}`],
|
||
]);
|
||
|
||
if (!m) {
|
||
fillKv($('res-axis'), [['상태', '생성 불가 (알림 참조)']]);
|
||
fillKv($('res-column'), []);
|
||
fillKv($('res-found'), []);
|
||
} else {
|
||
fillKv($('res-axis'), [
|
||
['IP 길이 / 축 방위각', `${f3(m.lengthIp)} m / ${m.azimuthDeg.toFixed(2)}°`],
|
||
['받침블럭 높이 전열 / 후열', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)} mm`],
|
||
['계단 경계 (BP1 기준)', m.segments.slice(1).map((s) => f3(s.s0)).join(' / ') + ' m'],
|
||
['횡방향 단차', '없음'],
|
||
['코핑 하면 EL 끝단 / Level', `${f3(m.endBottom)} / ${f3(m.levelBottom)} m`],
|
||
]);
|
||
fillKv($('res-column'), [
|
||
['기둥 높이', `${f3(m.derived.columnHeight)} m`],
|
||
['기둥 중심', pt(m.pivot)],
|
||
]);
|
||
fillKv($('res-found'), [
|
||
['확대기초 상면 EL', m.footings.length ? `${f3(m.footings[0].top)} m` : '없음'],
|
||
['말뚝 배치', m.derived.pileLayout ?? '없음'],
|
||
['말뚝 수 / 선단 EL', `${m.derived.pileCount} 본 / ${m.derived.pileTipEl === null ? '없음' : f3(m.derived.pileTipEl) + ' m'}`],
|
||
['흙덮개', `${f3(m.derived.soilCover)} m`],
|
||
]);
|
||
}
|
||
|
||
const list = $<HTMLUListElement>('issues');
|
||
list.replaceChildren();
|
||
if (pier.issues.length === 0) list.appendChild(el('li', '알림 없음'));
|
||
for (const issue of pier.issues) {
|
||
const li = el('li');
|
||
li.append(el('span', issue.code, `code ${issue.level}`), document.createTextNode(` ${issue.message}`));
|
||
list.appendChild(li);
|
||
}
|
||
$('issue-count').textContent = `(${pier.issues.length}건)`;
|
||
}
|
||
|
||
function renderDefaults(): void {
|
||
const box = $<HTMLUListElement>('defaults');
|
||
box.replaceChildren();
|
||
const items = [
|
||
'[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용',
|
||
'[미확정] Q5 말뚝 지름 600 mm (강관) 기본값',
|
||
'[미확정] Q4 직접기초 버림콘크리트 적용',
|
||
...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`),
|
||
];
|
||
for (const text of items) box.appendChild(el('li', text));
|
||
}
|
||
|
||
/** Pier last brought into view in 교각 목록, so that a manual scroll is not undone by the next render. */
|
||
let lastScrolledPier: string | null = null;
|
||
|
||
function renderAll(): void {
|
||
updatePartsCount();
|
||
if (state.active !== lastScrolledPier) {
|
||
lastScrolledPier = state.active;
|
||
document.querySelector<HTMLButtonElement>('.pier-items [aria-pressed="true"]')?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
$('status').textContent = state.piers.length ? '' : '엑셀을 불러오면 교각 형상을 생성합니다.';
|
||
renderHeader();
|
||
renderPierList();
|
||
renderPanels();
|
||
renderEditCard();
|
||
renderPlan();
|
||
renderRebar();
|
||
renderRebarColor();
|
||
if (state.stage === 'quantity') renderQuantityScreen();
|
||
scheduleBackup();
|
||
}
|
||
|
||
// --- 속성 복사 (시안 ⑥, plan C8) ---------------------------------------------------------
|
||
/** Names of the pier targets ticked in the copy dialog. */
|
||
let copySelection = new Set<string>();
|
||
|
||
function copyTargets(): Pier[] {
|
||
const src = activePier();
|
||
const sameType = $<HTMLInputElement>('cp-same-type').checked;
|
||
return state.piers.filter((p) => p !== src && (!sameType || p.input.type === src?.input.type));
|
||
}
|
||
|
||
/** Targets that are both visible in the dialog and ticked. Hidden targets are never changed. */
|
||
function selectedCopyTargets(): Pier[] {
|
||
return copyTargets().filter((t) => copySelection.has(t.input.name));
|
||
}
|
||
|
||
function chosenCopyItems(): CopyItem[] {
|
||
return Array.from(document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]'))
|
||
.filter((i) => i.checked)
|
||
.map((i) => i.dataset.item as CopyItem);
|
||
}
|
||
|
||
function renderCopyTargets(): void {
|
||
const list = $<HTMLUListElement>('cp-targets');
|
||
list.replaceChildren();
|
||
for (const t of copyTargets()) {
|
||
const li = el('li');
|
||
const check = el('input');
|
||
check.type = 'checkbox';
|
||
check.checked = copySelection.has(t.input.name);
|
||
check.setAttribute('aria-label', `${t.input.name} 대상`);
|
||
check.addEventListener('change', () => {
|
||
if (check.checked) copySelection.add(t.input.name);
|
||
else copySelection.delete(t.input.name);
|
||
renderCopyPreview();
|
||
});
|
||
li.append(check, el('span', `${t.input.name} · ${t.input.type} · ${t.input.foundationType}`));
|
||
list.appendChild(li);
|
||
}
|
||
}
|
||
|
||
function renderCopyPreview(): void {
|
||
const src = activePier();
|
||
const items = chosenCopyItems();
|
||
const body = $<HTMLTableElement>('cp-preview').tBodies[0];
|
||
body.replaceChildren();
|
||
let applied = 0;
|
||
let skippedTotal = 0;
|
||
const selected = selectedCopyTargets();
|
||
for (const t of selected) {
|
||
if (!src) break;
|
||
const plan = planCopy(src.draft, t.draft, items);
|
||
const labels = plan.changes.map((c) => `${FIELD_LABELS[c.key]} ${c.from} → ${c.to}`);
|
||
const skipped = plan.skipped.map((k) => k.reason);
|
||
skippedTotal += skipped.length;
|
||
if (plan.changes.length) applied++;
|
||
const row = el('tr');
|
||
row.append(
|
||
el('td', t.input.name),
|
||
el('td', t.input.type),
|
||
el('td', String(plan.changes.length)),
|
||
el('td', String(skipped.length)),
|
||
el('td', [...labels, ...skipped].join(' · ') || '바뀌는 값 없음'),
|
||
);
|
||
body.appendChild(row);
|
||
}
|
||
const n = selected.length;
|
||
$('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`;
|
||
const apply = $<HTMLButtonElement>('cp-apply');
|
||
apply.textContent = `대상 ${applied}기에 적용`;
|
||
apply.disabled = applied === 0;
|
||
}
|
||
|
||
function openCopyDialog(): void {
|
||
const src = activePier();
|
||
if (!src) return;
|
||
$('cp-source').textContent = `${src.input.name} · ${src.input.type} · ${src.input.foundationType}`;
|
||
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
|
||
i.checked = (DEFAULT_COPY_ITEMS as string[]).includes(i.dataset.item ?? '');
|
||
}
|
||
copySelection = new Set(state.piers.filter((p) => p.checked && p !== src).map((p) => p.input.name));
|
||
renderCopyTargets();
|
||
renderCopyPreview();
|
||
$<HTMLDialogElement>('copy-dialog').showModal();
|
||
}
|
||
|
||
function applyCopyDialog(): void {
|
||
const src = activePier();
|
||
if (!src) return;
|
||
const items = chosenCopyItems();
|
||
const regen = $<HTMLInputElement>('cp-regen').checked;
|
||
let applied = 0;
|
||
let changes = 0;
|
||
const targets = new Set(selectedCopyTargets().map((t) => t.input.name));
|
||
state.piers = state.piers.map((t) => {
|
||
if (t === src || !targets.has(t.input.name)) return t;
|
||
const plan = planCopy(src.draft, t.draft, items);
|
||
if (plan.changes.length === 0) return t;
|
||
applied++;
|
||
changes += plan.changes.length;
|
||
const next = { ...t, draft: applyCopy(t.draft, plan) };
|
||
return regen ? regenerateEntry(next) : next;
|
||
});
|
||
$<HTMLDialogElement>('copy-dialog').close();
|
||
setupScene();
|
||
renderAll();
|
||
setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`);
|
||
}
|
||
|
||
// --- input cards and change tracking (plan B1 · C2) -------------------------------------
|
||
function pendingTotal(): number {
|
||
return state.piers.filter((p) => p.checked).reduce((n, p) => n + pendingKeys(p).length, 0);
|
||
}
|
||
|
||
function fieldLabel(key: EditableKey): string {
|
||
return FIELD_LABELS[key];
|
||
}
|
||
|
||
function editField(name: string, key: EditableKey, raw: string): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
const value = Number(raw);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
setError(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`);
|
||
renderAll();
|
||
return;
|
||
}
|
||
p.draft = { ...p.draft, [key]: value };
|
||
renderAll();
|
||
}
|
||
|
||
function revertField(name: string, key: EditableKey): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
p.draft = revertDraft(p, key);
|
||
renderAll();
|
||
}
|
||
|
||
function regenerateOne(name: string): void {
|
||
const i = state.piers.findIndex((q) => q.input.name === name);
|
||
if (i < 0) return;
|
||
state.piers[i] = regenerateEntry(state.piers[i]);
|
||
setupScene();
|
||
renderAll();
|
||
const msg = state.piers[i].model ? '형상을 다시 생성했습니다.' : '형상을 생성하지 못했습니다. 알림을 확인하세요.';
|
||
setStatus(msg);
|
||
}
|
||
|
||
function renderEditCard(): void {
|
||
const pier = activePier();
|
||
const btn = $<HTMLButtonElement>('edit-regen');
|
||
if (!pier) {
|
||
$<HTMLDivElement>('edit-fields').replaceChildren();
|
||
$('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.';
|
||
btn.textContent = '다시 생성 · 변경 0';
|
||
btn.disabled = true;
|
||
return;
|
||
}
|
||
const status = fieldRows($<HTMLDivElement>('edit-fields'), pier, EDITABLE_FIELDS.map((f) => f.key));
|
||
$('edit-summary').textContent = status.pending.length
|
||
? `미반영 변경 ${status.pending.length}건. 3D 그림은 생성본을 유지합니다.`
|
||
: status.overrides.length
|
||
? `기본값과 ${status.overrides.length}건 다릅니다. 생성본이 최신입니다.`
|
||
: '기본값과 같습니다.';
|
||
btn.textContent = `다시 생성 · 변경 ${status.pending.length}`;
|
||
btn.disabled = status.pending.length === 0;
|
||
}
|
||
|
||
/** One row per input key: value in the draft, the imported default beside it, and a revert button when overridden. */
|
||
function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { overrides: EditableKey[]; pending: EditableKey[] } {
|
||
box.replaceChildren();
|
||
const overrides = overrideKeys(pier);
|
||
const pending = pendingKeys(pier);
|
||
for (const f of EDITABLE_FIELDS.filter((x) => keys.includes(x.key))) {
|
||
const changed = overrides.includes(f.key);
|
||
const row = el('div', undefined, changed ? 'edit-row changed' : 'edit-row');
|
||
const id = `edit-${f.key}`;
|
||
const label = el('label', `${f.label} (${f.unit})`);
|
||
label.htmlFor = id;
|
||
const input = el('input');
|
||
input.id = id;
|
||
input.type = 'number';
|
||
input.step = 'any';
|
||
input.value = String(pier.draft[f.key]);
|
||
input.addEventListener('change', () => editField(pier.input.name, f.key, input.value));
|
||
row.append(label, input, el('span', `기본 ${pier.defaults[f.key]}`, 'muted small'));
|
||
if (changed) {
|
||
const revert = el('button', '되돌리기', 'btn small');
|
||
revert.type = 'button';
|
||
revert.addEventListener('click', () => revertField(pier.input.name, f.key));
|
||
row.appendChild(revert);
|
||
}
|
||
box.appendChild(row);
|
||
}
|
||
return { overrides, pending };
|
||
}
|
||
|
||
/** Footing length of the generated model, which the 열별 본수 limits depend on. */
|
||
function footingLengthOf(p: Pier): number | undefined {
|
||
return p.model?.footings[0]?.length;
|
||
}
|
||
|
||
/** 확대기초·말뚝 그림 (2단계, 승인 2026-10-23): plan and side views drawn from the model; θ is shown as a note. */
|
||
/**
|
||
* The 말뚝 layout (승인 2026-10-23 추천안): the 열별 본수 card on the left, the plan beside it, the side view below.
|
||
* The card and the plan share one row; on narrow screens they stack.
|
||
*/
|
||
function renderFootingDrawings(box: HTMLDivElement, pier: Pier): void {
|
||
if (!pier.model) return;
|
||
const grid = el('div', undefined, 'footing-grid');
|
||
const cardCol = el('div', undefined, 'footing-card-col');
|
||
renderPileRows(cardCol, pier);
|
||
const plan = el('figure', undefined, 'footing-fig');
|
||
plan.innerHTML = footingPlanSvg(pier.model);
|
||
plan.appendChild(el('figcaption', '평면 · 1열이 아래, 열은 교축과 직각 방향 줄', 'muted small'));
|
||
// The side view (정면) sits to the right of the plan, on the same scale (승인 2026-10-23).
|
||
const side = el('figure', undefined, 'footing-fig');
|
||
side.innerHTML = footingSideSvg(pier.model);
|
||
side.appendChild(el('figcaption', '정면 · 끊음 표시: 기둥과 말뚝 일부 생략 · 점선 = 경사 선택 시 상면 (L2, h2)', 'muted small'));
|
||
grid.append(cardCol, plan, side);
|
||
box.appendChild(grid);
|
||
box.appendChild(el('p', '기초 회전각 θ는 교각축 대비 회전으로, 생성 규칙은 spec에 추가 후 적용합니다 (지금은 0°).', 'muted small'));
|
||
}
|
||
|
||
/** 말뚝 규격 (승인 2026-10-23): 강관 and PHC from their lists by outer diameter and wall; the inner diameter, area and weight are read. */
|
||
function renderPileSpec(box: HTMLDivElement, pier: Pier): void {
|
||
const kind = pier.draft.pileKind ?? '강관';
|
||
const list = kind === '강관' ? STEEL_PILES : PHC_PILES;
|
||
const cur = findPileSpec(kind, pier.draft.pileOuterMm ?? 609.6, pier.draft.pileThicknessMm ?? 12);
|
||
const wrap = el('div', undefined, 'pile-spec');
|
||
const kindSel = el('select');
|
||
kindSel.id = 'pile-kind';
|
||
for (const k of ['강관', 'PHC'] as const) {
|
||
const o = el('option', k === '강관' ? '강관 말뚝' : 'PHC 말뚝');
|
||
o.value = k;
|
||
kindSel.appendChild(o);
|
||
}
|
||
kindSel.value = kind;
|
||
kindSel.addEventListener('change', () => {
|
||
const first = (kindSel.value === '강관' ? STEEL_PILES : PHC_PILES)[0];
|
||
setPileSpec(pier.input.name, kindSel.value as '강관' | 'PHC', first.D, first.t);
|
||
});
|
||
const sizeSel = el('select');
|
||
sizeSel.id = 'pile-size';
|
||
for (const p of list) {
|
||
const o = el('option', `${p.D} × ${p.t}`);
|
||
o.value = `${p.D}|${p.t}`;
|
||
sizeSel.appendChild(o);
|
||
}
|
||
if (cur) sizeSel.value = `${cur.D}|${cur.t}`;
|
||
sizeSel.addEventListener('change', () => {
|
||
const [D, t] = sizeSel.value.split('|').map(Number);
|
||
setPileSpec(pier.input.name, kind, D, t);
|
||
});
|
||
const info = cur
|
||
? `내경 ${cur.inner} mm · 단면적 ${cur.area} cm² · 단위중량 ${cur.unitWeight} ${kind === '강관' ? 'kg/m' : 'kN/m'}`
|
||
: '목록에 없는 규격입니다.';
|
||
wrap.append(el('label', '말뚝 종류 '), kindSel, el('label', ' 외경 × 두께 '), sizeSel, el('p', info, 'muted small'));
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
function setPileSpec(name: string, kind: '강관' | 'PHC', D: number, t: number): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
p.draft = { ...p.draft, pileKind: kind, pileOuterMm: D, pileThicknessMm: t };
|
||
state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q));
|
||
setupScene();
|
||
renderAll();
|
||
}
|
||
|
||
/** 열별 본수 −/+ per 열 (spec §5.4). Shows the spacing, the total, and a warning below the minimum spacing. */
|
||
function renderPileRows(box: HTMLDivElement, pier: Pier): void {
|
||
const footingLength = footingLengthOf(pier);
|
||
if (pier.input.foundationType !== '말뚝기초' || footingLength === undefined) return;
|
||
const g = pileGeometry(footingLength);
|
||
let counts: number[];
|
||
try {
|
||
counts = resolveRowCounts(pier.draft.pileRows, g);
|
||
} catch (err) {
|
||
box.appendChild(el('p', (err as Error).message, 'warn-text'));
|
||
return;
|
||
}
|
||
const layout = rowLayout(counts, g);
|
||
const wrap = el('div', undefined, 'pile-rows');
|
||
wrap.appendChild(el('p', `열별 본수 · 합계 ${totalPiles(counts)}본`, 'lbl'));
|
||
counts.forEach((count, j) => {
|
||
const row = el('div', undefined, layout[j].belowMinimum ? 'pile-row warn' : 'pile-row');
|
||
const minus = el('button', '−', 'btn small');
|
||
minus.type = 'button';
|
||
minus.setAttribute('aria-label', `열 ${j + 1} 본수 줄이기`);
|
||
minus.addEventListener('click', () => setPileRow(pier.input.name, j, -1));
|
||
const plus = el('button', '+', 'btn small');
|
||
plus.type = 'button';
|
||
plus.setAttribute('aria-label', `열 ${j + 1} 본수 늘리기`);
|
||
plus.addEventListener('click', () => setPileRow(pier.input.name, j, 1));
|
||
row.append(el('span', `열 ${j + 1}`), minus, el('span', `${count}본`, 'num'), plus, el('span', `간격 ${Math.round(layout[j].spacing * 1000)} mm`, 'num'));
|
||
if (layout[j].belowMinimum) row.append(el('span', `최소 ${Math.round(g.minSpacing * 1000)} mm보다 좁습니다`, 'warn-text'));
|
||
wrap.appendChild(row);
|
||
});
|
||
wrap.appendChild(el('p', layoutText(counts, g), 'num'));
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
function setPileRow(name: string, row: number, delta: number): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
const length = p && footingLengthOf(p);
|
||
if (!p || length === undefined) return;
|
||
const counts = resolveRowCounts(p.draft.pileRows, pileGeometry(length));
|
||
p.draft = { ...p.draft, pileRows: adjustRow(counts, row, delta) };
|
||
renderAll();
|
||
}
|
||
|
||
/** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */
|
||
function renderShapeOptions(member: MemberId, pier?: Pier): void {
|
||
const box = $<HTMLDivElement>('member-options');
|
||
box.replaceChildren();
|
||
SHAPE_GROUPS[member].forEach((group, gi) => {
|
||
const row = el('div', undefined, 'shape-row');
|
||
row.appendChild(el('span', group.title, 'lbl'));
|
||
for (const option of group.options) {
|
||
const chip = el('button', option.label, 'chip-opt');
|
||
chip.type = 'button';
|
||
// The ground 위치 is stored in the pier's draft, so its pressed chip is the chosen one (승인 2026-10-26).
|
||
const chosen = member === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : undefined;
|
||
chip.setAttribute('aria-pressed', String(chosen !== undefined ? option.label === chosen : option.isDefault));
|
||
chip.disabled = !isSelectable(option);
|
||
if (chip.disabled) chip.title = UNDEFINED_REASON;
|
||
// 일체 needs every front–back pair within 100 mm (승인 2026-10-26); the reason is shown on the chip.
|
||
if (member === 'bearing' && option.label === '일체' && pier?.model) {
|
||
const check = integratedCheck(pier.model.blocks);
|
||
chip.disabled = !check.allowed;
|
||
chip.title = check.allowed ? '' : `편심 100 mm 초과 쌍 ${check.overLimit.length}개 → 분리 우선`;
|
||
}
|
||
chip.addEventListener('click', () => {
|
||
try {
|
||
choose(member, gi, option.label);
|
||
if (member === 'ground' && pier) editGround(pier.input.name, 'groundPosition', option.label);
|
||
if (member === 'bearing' && pier) editBearingLayout(pier.input.name, option.label as '분리' | '일체');
|
||
} catch (err) {
|
||
setError((err as Error).message);
|
||
}
|
||
});
|
||
row.appendChild(chip);
|
||
}
|
||
box.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// --- ② 형상계획 (plan.md C1) -------------------------------------------------------------
|
||
/**
|
||
* The 3D viewer block (views, canvas, part toggles) is shared: it sits in ① and moves into the top slot of
|
||
* ② or ③ while that screen is shown. Moving the element keeps the WebGL context.
|
||
*/
|
||
const viewerHome: { parent: Node | null; next: Node | null } = { parent: null, next: null };
|
||
|
||
function captureViewerHome(): void {
|
||
const block = $<HTMLElement>('viewer-block');
|
||
viewerHome.parent = block.parentElement;
|
||
viewerHome.next = block.nextElementSibling;
|
||
}
|
||
|
||
function placeViewer(stage: Stage): void {
|
||
const block = $<HTMLElement>('viewer-block');
|
||
const slot = stage === 'plan' ? $<HTMLElement>('slot-plan') : stage === 'rebar' ? $<HTMLElement>('slot-rebar') : null;
|
||
if (slot) slot.appendChild(block);
|
||
else if (viewerHome.parent) viewerHome.parent.insertBefore(block, viewerHome.next);
|
||
renderRebarColor();
|
||
resize();
|
||
}
|
||
|
||
/** The 철근표 group that a bar name belongs to. */
|
||
function memberOfBar(name: string): RebarRow['member'] {
|
||
if (name === 'S1' || name === 'S2') return 'bearing';
|
||
if (name.startsWith('B')) return 'footing';
|
||
if (name.startsWith('P') || name.includes('커플러')) return 'column';
|
||
return 'coping';
|
||
}
|
||
|
||
/** Click on a 3D bar selects its 철근표 row (on ③ only). */
|
||
function wireRebarPicking(): void {
|
||
const dom = renderer.domElement;
|
||
let down: { x: number; y: number } | null = null;
|
||
dom.addEventListener('pointerdown', (e) => {
|
||
down = { x: e.clientX, y: e.clientY };
|
||
});
|
||
dom.addEventListener('pointerup', (e) => {
|
||
if (!down || state.stage !== 'rebar' || !rebar3d) return;
|
||
if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return; // a drag rotates the view
|
||
const rect = dom.getBoundingClientRect();
|
||
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
||
const ray = new THREE.Raycaster();
|
||
ray.setFromCamera(ndc, camera);
|
||
const hit = ray.intersectObjects(rebar3d.group.children, false).find((h) => h.object.visible);
|
||
if (!hit) return;
|
||
const name = hit.object.userData.name as string;
|
||
state.rebarSelection = new Set([name]);
|
||
state.rebarMember = memberOfBar(name);
|
||
renderAll();
|
||
});
|
||
}
|
||
|
||
function setStage(stage: Stage): void {
|
||
state.stage = stage;
|
||
if (state.scene) setRebarView(state.scene, stage === 'rebar');
|
||
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
|
||
$<HTMLElement>('view-plan').hidden = stage !== 'plan';
|
||
$<HTMLElement>('view-rebar').hidden = stage !== 'rebar';
|
||
$<HTMLElement>('view-quantity').hidden = stage !== 'quantity';
|
||
if (stage === 'quantity') renderQuantityScreen();
|
||
// Move the viewer after its new parent is visible, so that resize() measures the real size.
|
||
placeViewer(stage);
|
||
if (rebar3d) rebar3d.group.visible = stage === 'rebar';
|
||
// The highlight is the 'current' class (style.css); aria-current is set alongside it for assistive tech.
|
||
const nav: [string, Stage][] = [['nav-shape', 'shape'], ['nav-plan', 'plan'], ['nav-rebar', 'rebar'], ['nav-quantity', 'quantity']];
|
||
for (const [id, key] of nav) {
|
||
const btn = $<HTMLButtonElement>(id);
|
||
btn.classList.toggle('current', stage === key);
|
||
btn.setAttribute('aria-current', String(stage === key));
|
||
}
|
||
renderAll();
|
||
}
|
||
|
||
// --- ③ 철근배근 (plan C5 · ③ screen v1) --------------------------------------------------
|
||
const REBAR_MEMBERS: { id: RebarRow['member']; label: string }[] = [
|
||
{ id: 'bearing', label: '받침블럭' },
|
||
{ id: 'coping', label: '코핑' },
|
||
{ id: 'column', label: '기둥' },
|
||
{ id: 'footing', label: '확대기초' },
|
||
];
|
||
|
||
/** 3D colour basis buttons on ③ and their legend (spec §6.7). */
|
||
function renderRebarColor(): void {
|
||
const colorLabel: Record<RebarColorMode, string> = { role: '역할', diameter: '직경', member: '부재' };
|
||
$('color-state').textContent = `${colorLabel[state.rebarColor]} ▾`;
|
||
// The colour control lives in the 3D window and is shown only on ③.
|
||
$<HTMLDetailsElement>('color-info').hidden = state.stage !== 'rebar';
|
||
for (const b of document.querySelectorAll<HTMLButtonElement>('#rebar-color button')) {
|
||
b.setAttribute('aria-pressed', String(b.dataset.color === state.rebarColor));
|
||
}
|
||
const legend = $<HTMLUListElement>('rebar-legend');
|
||
legend.replaceChildren();
|
||
const items = rebar3d?.legend(state.rebarColor) ?? [];
|
||
if (state.rebarColor === 'role') {
|
||
legend.appendChild(el('li', '역할별 색 (명세 §6.7)', 'muted small'));
|
||
}
|
||
for (const item of items) {
|
||
const li = el('li', undefined, 'rebar-legend-item');
|
||
const swatch = el('span', undefined, 'swatch');
|
||
swatch.style.background = item.color;
|
||
li.append(swatch, el('span', item.label));
|
||
legend.appendChild(li);
|
||
}
|
||
}
|
||
|
||
for (const b of document.querySelectorAll<HTMLButtonElement>('#rebar-color button')) {
|
||
b.addEventListener('click', () => {
|
||
state.rebarColor = b.dataset.color as RebarColorMode;
|
||
rebar3d?.setColorMode(state.rebarColor);
|
||
renderRebarColor();
|
||
});
|
||
}
|
||
|
||
/** 받침블럭 배근 설정 (승인 2026-10-26, 시안 ③): the six inputs (ranges shown), the 적용 블럭, and the C–C section. */
|
||
const BLOCK_REBAR_FIELDS: { key: keyof BlockRebarSettings; label: string }[] = [
|
||
{ key: 'coverTop', label: '상면 피복 (철근 중심, mm)' },
|
||
{ key: 'coverSide', label: '측면 피복 (mm)' },
|
||
{ key: 'firstGap', label: '첫 철근 최소 여유 (mm)' },
|
||
{ key: 'spacingS1', label: '간격 S1 (mm)' },
|
||
{ key: 'spacingS2', label: '간격 S2 (mm)' },
|
||
{ key: 'legLength', label: '다리 길이 (mm)' },
|
||
];
|
||
|
||
function setBlockRebar(name: string, key: keyof BlockRebarSettings, value: number): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
if (!Number.isFinite(value)) {
|
||
setError('받침블럭 철근 값은 숫자로 입력하세요. 값을 되돌렸습니다.');
|
||
renderAll();
|
||
return;
|
||
}
|
||
p.draft = { ...p.draft, blockRebar: { ...p.draft.blockRebar, [key]: value } };
|
||
regenerateOne(name);
|
||
}
|
||
|
||
function renderBlockRebarSettings(box: HTMLDivElement, pier: Pier): void {
|
||
if (!pier.model) return;
|
||
const wrap = el('div', undefined, 'bearing-ref');
|
||
const current: BlockRebarSettings = { ...DEFAULT_BLOCK_REBAR, ...pier.draft.blockRebar };
|
||
wrap.appendChild(el('p', '받침블럭 배근 설정 (기본값 = P1 · 범위는 칸 옆에 표시)', 'lbl'));
|
||
// 단면 쌍 (the same selection as ②): the C–C section cuts through one pair.
|
||
const pairList = pairEccentricities(pier.model.blocks);
|
||
const chipRow = el('div', undefined, 'opts');
|
||
pairList.forEach((p, i) => {
|
||
const chip = el('button', `${PAIR_CHIP_LABELS[p.frontNo - 1] ?? p.frontNo}·${PAIR_CHIP_LABELS[p.backNo - 1] ?? p.backNo}`, 'btn small');
|
||
chip.type = 'button';
|
||
chip.setAttribute('aria-pressed', String(i === bearingPairIndex));
|
||
chip.addEventListener('click', () => {
|
||
bearingPairIndex = i;
|
||
renderAll();
|
||
});
|
||
chipRow.appendChild(chip);
|
||
});
|
||
wrap.appendChild(chipRow);
|
||
const grid = el('div', undefined, 'edit-fields');
|
||
for (const f of BLOCK_REBAR_FIELDS) {
|
||
const row = el('div', undefined, 'edit-row');
|
||
const label = el('label', f.label);
|
||
const input = el('input');
|
||
input.type = 'number';
|
||
input.step = '1';
|
||
input.value = String(current[f.key]);
|
||
input.addEventListener('change', () => setBlockRebar(pier.input.name, f.key, Number(input.value)));
|
||
const [lo, hi] = BLOCK_REBAR_RANGES[f.key];
|
||
row.append(label, input, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_BLOCK_REBAR[f.key]}`, 'muted small'));
|
||
grid.appendChild(row);
|
||
}
|
||
wrap.appendChild(grid);
|
||
const n = pier.model.integratedBlocks.length;
|
||
wrap.appendChild(el('p', `적용 블럭: ${n > 0 ? `일체 ${n}쌍` : `분리 ${pier.model.blocks.length}개`} (전체)`, 'muted small'));
|
||
const fig = el('figure', undefined, 'ground-fig');
|
||
const pairIdx = Math.min(bearingPairIndex, Math.max(0, pairEccentricities(pier.model.blocks).length - 1));
|
||
// 단면 C–C / 평면 (승인 2026-10-26): the two views of the same settings; the 평면 shows the legs as dots and the row numbers.
|
||
const viewRow = el('div', undefined, 'opts');
|
||
for (const [v, label] of [['section', '단면 C–C'], ['plan', '평면']] as const) {
|
||
const b = el('button', label, 'btn small');
|
||
b.type = 'button';
|
||
b.setAttribute('aria-pressed', String(bearingRebarView === v));
|
||
b.addEventListener('click', () => {
|
||
bearingRebarView = v;
|
||
renderAll();
|
||
});
|
||
viewRow.appendChild(b);
|
||
}
|
||
wrap.appendChild(viewRow);
|
||
fig.innerHTML = bearingRebarView === 'plan' ? blockRebarPlanSvg(pier.model, pairIdx) : blockRebarSectionSvg(pier.model, pairIdx);
|
||
fig.appendChild(el('figcaption', bearingRebarView === 'plan' ? '평면 · 선택한 쌍 · S2 선(교각축 방향), S1 선(교축 방향) · 다리는 점 · 번호는 표의 행 번호' : '단면 C–C · 교축 방향 · 선택한 쌍 · S2 점(상면), S1 선과 다리(⊓) · 치수 mm', 'muted small'));
|
||
wrap.appendChild(fig);
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
/** 코핑 배근 (승인 2026-10-26, 추천): 정면, and the 단면·폐합 at a station the user chooses (default mid-length). */
|
||
let copingView: 'front' | 'section' = 'front';
|
||
|
||
/** 코핑 배근 입력 (승인 2026-10-26, 제안 = RAG/코핑_입력범위_제안.md). Order follows the ③ 코핑 배근 panel. */
|
||
const COPING_REBAR_FIELDS: { key: keyof CopingRebarSettings; label: string }[] = [
|
||
{ key: 'cover', label: '피복 (철근 중심, mm)' },
|
||
{ key: 'mainSpacing', label: '주철근 기본간격 (mm)' },
|
||
{ key: 'mainEndGap', label: '주철근 끝단 최소 여유 (mm)' },
|
||
{ key: 'mainLayerGap', label: '상면 주철근 단 간격 · 세로 (mm, 가로는 d 고정)' },
|
||
{ key: 'firstShearGap', label: '첫 전단철근 최소 여유 (피복 다음, mm)' },
|
||
{ key: 'shearLegs', label: '전단철근 다리 수' },
|
||
{ key: 'spacingS', label: '전단철근 간격 · 경사 (mm)' },
|
||
{ key: 'spacingL', label: '전단철근 간격 · Level (mm)' },
|
||
{ key: 'sideSpacing', label: '측면 수평 간격 (mm)' },
|
||
{ key: 'sideEndGap', label: '측면 위 끝 아래 최소 여유 (mm)' },
|
||
{ key: 'c14UpperSpacing', label: 'C14 간격 위 2줄 (mm)' },
|
||
{ key: 'c14LowerSpacing', label: 'C14 간격 아래 줄 (mm)' },
|
||
];
|
||
|
||
/** 적용: the values go to the draft and the model is rebuilt only when every check passes (CP01·CP02). */
|
||
function applyCopingRebar(name: string, next: CopingRebarSettings, splice: TopSpliceSettings): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
p.draft = { ...p.draft, copingRebar: next, topSplice: splice };
|
||
regenerateOne(name);
|
||
}
|
||
|
||
/** A row of toggle buttons (aria-pressed), as on the 배치 chips of ②. */
|
||
function toggleRow<T extends string>(options: [T, string][], current: T, onPick: (v: T) => void): HTMLDivElement {
|
||
const row = el('div', undefined, 'opts');
|
||
for (const [v, label] of options) {
|
||
const b = el('button', label, 'btn small');
|
||
b.type = 'button';
|
||
b.setAttribute('aria-pressed', String(v === current));
|
||
b.addEventListener('click', () => onPick(v));
|
||
row.appendChild(b);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
function renderCopingSettings(box: HTMLDivElement, pier: Pier): void {
|
||
if (!pier.model) return;
|
||
const wrap = el('div', undefined, 'bearing-ref');
|
||
const current: CopingRebarSettings = { ...DEFAULT_COPING_REBAR, ...pier.draft.copingRebar };
|
||
wrap.appendChild(el('p', '코핑 배근 입력 (기본값 = 코핑철근.md · 적용을 눌러야 반영)', 'lbl'));
|
||
const grid = el('div', undefined, 'edit-fields');
|
||
const controls = new Map<keyof CopingRebarSettings, HTMLInputElement | HTMLSelectElement>();
|
||
for (const f of COPING_REBAR_FIELDS) {
|
||
const row = el('div', undefined, 'edit-row');
|
||
const label = el('label', f.label);
|
||
let control: HTMLInputElement | HTMLSelectElement;
|
||
if (f.key === 'shearLegs') {
|
||
// 다리 수는 짝수만 고르도록 목록으로 둔다 (CP02).
|
||
const sel = el('select');
|
||
for (const n of [4, 6, 8]) {
|
||
const opt = el('option', String(n));
|
||
opt.value = String(n);
|
||
sel.appendChild(opt);
|
||
}
|
||
sel.value = String(current.shearLegs);
|
||
control = sel;
|
||
} else {
|
||
const input = el('input');
|
||
input.type = 'number';
|
||
input.step = 'any';
|
||
input.value = String(current[f.key]);
|
||
control = input;
|
||
}
|
||
control.id = `coping-${f.key}`;
|
||
label.htmlFor = control.id;
|
||
controls.set(f.key, control);
|
||
const [lo, hi] = COPING_REBAR_RANGES[f.key];
|
||
const changed = current[f.key] !== DEFAULT_COPING_REBAR[f.key] ? ' · 기본과 다름' : '';
|
||
row.append(label, control, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_COPING_REBAR[f.key]}${changed}`, 'muted small'));
|
||
grid.appendChild(row);
|
||
}
|
||
wrap.appendChild(grid);
|
||
// 상면 주철근(C1–C6) 이음 (승인 2026-10-26): 무시 (사양 기본) / 반영, and the 위치 when 반영.
|
||
let splice: TopSpliceSettings = { ...DEFAULT_TOP_SPLICE, ...pier.draft.topSplice };
|
||
const spliceBox = el('div', undefined, 'bearing-ref');
|
||
const drawSplice = (): void => {
|
||
spliceBox.replaceChildren(el('p', '상면 주철근(C1–C6) 이음', 'muted small'), toggleRow([['ignore', '이음 무시'], ['apply', '반영']] as const, splice.mode, (v) => {
|
||
splice = { ...splice, mode: v };
|
||
drawSplice();
|
||
}));
|
||
if (splice.mode === 'apply') {
|
||
spliceBox.appendChild(toggleRow([['center', '기둥 중심'], ['end', '단부']] as const, splice.at, (v) => {
|
||
splice = { ...splice, at: v };
|
||
drawSplice();
|
||
}));
|
||
}
|
||
};
|
||
drawSplice();
|
||
wrap.appendChild(spliceBox);
|
||
const msg = el('p', undefined, 'muted small');
|
||
const apply = el('button', '적용', 'btn small');
|
||
apply.type = 'button';
|
||
apply.addEventListener('click', () => {
|
||
const next = { ...current };
|
||
for (const f of COPING_REBAR_FIELDS) next[f.key] = Number(controls.get(f.key)?.value);
|
||
const issues = copingSettingsIssues(next);
|
||
if (issues.length > 0) {
|
||
msg.textContent = issues.map((i) => i.message).join(' · ');
|
||
setError('코핑 배근 입력을 확인하세요. 값은 반영되지 않았습니다.');
|
||
return;
|
||
}
|
||
applyCopingRebar(pier.input.name, next, splice);
|
||
});
|
||
wrap.append(msg, apply);
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
/** 기둥 배근 입력 (승인 2026-10-26, 시안 ③ 기둥 삽도). The 주철근 하단 갈고리 is fixed. The 띠철근 간격 is three inputs (K3 · K5 · K4). */
|
||
const COLUMN_REBAR_FIELDS: { key: keyof ColumnRebarSettings; label: string }[] = [
|
||
{ key: 'c', label: 'P1 중심 피복 c (mm)' },
|
||
{ key: 'ctcP3', label: '띠철근 간격 P3 (mm)' },
|
||
{ key: 'ctcP5', label: '띠철근 간격 P5 (mm)' },
|
||
{ key: 'ctcP4', label: '띠철근 간격 P4 (mm)' },
|
||
{ key: 'ctcP6', label: '크로스타이 P6 간격 (mm)' },
|
||
{ key: 'ctcCoupler', label: '커플러 간격 ctcOfcoupler (mm)' },
|
||
{ key: 'dzCp2', label: 'P2 커플러 상승 Δz (mm)' },
|
||
{ key: 'nDiv', label: '주철근 원주 등분 수 n_div' },
|
||
];
|
||
|
||
/** 적용: the values go to the draft and the model is rebuilt only when every check passes (CL01·CL02). */
|
||
function applyColumnRebar(name: string, next: ColumnRebarSettings): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
p.draft = { ...p.draft, columnRebar: next };
|
||
regenerateOne(name);
|
||
}
|
||
|
||
function renderColumnSettings(box: HTMLDivElement, pier: Pier): void {
|
||
if (!pier.model) return;
|
||
const wrap = el('div', undefined, 'bearing-ref');
|
||
const current: ColumnRebarSettings = { ...DEFAULT_COLUMN_REBAR, ...pier.draft.columnRebar };
|
||
wrap.appendChild(el('p', '기둥 배근 입력 (기본값 = 기둥철근 (1).md · 적용을 눌러야 반영)', 'lbl'));
|
||
const grid = el('div', undefined, 'edit-fields');
|
||
const inputs = new Map<keyof ColumnRebarSettings, HTMLInputElement>();
|
||
for (const f of COLUMN_REBAR_FIELDS) {
|
||
const row = el('div', undefined, 'edit-row');
|
||
const label = el('label', f.label);
|
||
const input = el('input');
|
||
input.type = 'number';
|
||
input.step = 'any';
|
||
input.value = String(current[f.key]);
|
||
input.id = `column-${f.key}`;
|
||
label.htmlFor = input.id;
|
||
inputs.set(f.key, input);
|
||
const [lo, hi] = COLUMN_REBAR_RANGES[f.key];
|
||
const changed = current[f.key] !== DEFAULT_COLUMN_REBAR[f.key] ? ' · 기본과 다름' : '';
|
||
row.append(label, input, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_COLUMN_REBAR[f.key]}${changed}`, 'muted small'));
|
||
grid.appendChild(row);
|
||
}
|
||
wrap.appendChild(grid);
|
||
// Read-only results of the current model (계산 값).
|
||
const input = columnInputFor(pier.model);
|
||
const col = columnRebar(input);
|
||
wrap.appendChild(el('p', `P1 외측 r ${col.r1} · P2 내측 r ${col.r2} mm · 주철근 P1 ${col.mainP1}본 · P2 ${col.mainP2}본 · 주철근 커플러 ${col.couplers}개`, 'muted small'));
|
||
// 정면 | 단면 A·B·C (승인 2026-10-26): 치수는 입력과 계산 결과, 간섭(B11–P4)은 검토 제외.
|
||
let columnView: 'front' | 'section' = 'front';
|
||
const figBox = el('div');
|
||
const drawColumnFigure = (): void => {
|
||
figBox.replaceChildren(toggleRow([['front', '정면'], ['section', '단면 A·B·C']] as const, columnView, (v) => {
|
||
columnView = v;
|
||
drawColumnFigure();
|
||
}));
|
||
if (columnView === 'front') {
|
||
const fig = el('figure', undefined, 'ground-fig');
|
||
fig.innerHTML = columnElevationSvg(input, col);
|
||
fig.appendChild(el('figcaption', '정면 · 높이 기준 = 확대기초 상면 (mm) · 간섭(B11–P4)은 검토 제외', 'muted small'));
|
||
figBox.appendChild(fig);
|
||
return;
|
||
}
|
||
const captions: Record<ColumnSection, string> = {
|
||
A: 'A 일반 · P3 첫 단 · 주철근 P1·P2 · 띠철근 P3 (겹침 표시)',
|
||
B: 'B 단부 · P5 첫 단 · 커플러 1개 (θ + Δθ/2)',
|
||
C: 'C 확대기초 안 · P4 첫 단 · 커플러 2개 · P6 4본',
|
||
};
|
||
// 단면 A·B·C in one row (승인 2026-10-26): no wrapping, scrolls sideways on a narrow window.
|
||
const row = el('div');
|
||
row.style.display = 'flex';
|
||
row.style.flexWrap = 'nowrap';
|
||
row.style.gap = '12px';
|
||
row.style.overflowX = 'auto';
|
||
for (const kind of ['A', 'B', 'C'] as const) {
|
||
const fig = el('figure', undefined, 'ground-fig');
|
||
fig.style.margin = '0';
|
||
fig.style.flex = '0 0 300px';
|
||
fig.innerHTML = columnSectionSvg(kind, input, col);
|
||
fig.appendChild(el('figcaption', captions[kind], 'muted small'));
|
||
row.appendChild(fig);
|
||
}
|
||
figBox.appendChild(row);
|
||
};
|
||
drawColumnFigure();
|
||
wrap.appendChild(figBox);
|
||
const msg = el('p', undefined, 'muted small');
|
||
const apply = el('button', '적용', 'btn small');
|
||
apply.type = 'button';
|
||
apply.addEventListener('click', () => {
|
||
const next = { ...current };
|
||
for (const f of COLUMN_REBAR_FIELDS) next[f.key] = Number(inputs.get(f.key)?.value);
|
||
const issues = columnSettingsIssues(next);
|
||
if (issues.length > 0) {
|
||
msg.textContent = issues.map((i) => i.message).join(' · ');
|
||
setError('기둥 배근 입력을 확인하세요. 값은 반영되지 않았습니다.');
|
||
return;
|
||
}
|
||
applyColumnRebar(pier.input.name, next);
|
||
});
|
||
wrap.append(msg, apply);
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void {
|
||
if (!pier.model || !rebar3d) return;
|
||
const L = pier.model.copingL;
|
||
const wrap = el('div', undefined, 'bearing-ref');
|
||
const row = el('div', undefined, 'opts');
|
||
for (const [v, label] of [['front', '정면'], ['section', '단면 · 폐합 구성']] as const) {
|
||
const b = el('button', label, 'btn small');
|
||
b.type = 'button';
|
||
b.setAttribute('aria-pressed', String(copingView === v));
|
||
b.addEventListener('click', () => {
|
||
copingView = v;
|
||
renderAll();
|
||
});
|
||
row.appendChild(b);
|
||
}
|
||
wrap.appendChild(row);
|
||
const fig = el('figure', undefined, 'ground-fig');
|
||
if (copingView === 'front') {
|
||
fig.innerHTML = copingFrontSvg(rebar3d.records, L * 1000, pier.model.columns[0]?.s ?? 0);
|
||
fig.appendChild(el('figcaption', '정면 · 교축 방향에서 본 코핑 배근 (3D 철근과 같은 데이터)', 'muted small'));
|
||
} else {
|
||
// 단면 · 폐합 at two fixed stations side by side (승인 2026-10-26): the 단부 (first shear station) and the first column centre.
|
||
const sEnd = copingEndStation(rebar3d.records);
|
||
const sCol = pier.model.columns[0]?.s ?? L / 2;
|
||
const grid = el('div', undefined, 'footing-grid');
|
||
for (const [st, label] of [[sEnd, `단부 s = ${sEnd.toFixed(2)} m`], [sCol, `기둥 중심 s = ${sCol.toFixed(2)} m`]] as const) {
|
||
const f = el('figure', undefined, 'footing-fig');
|
||
f.innerHTML = copingSectionSvg(rebar3d.records, st, pier.model.copingRefTop, pier.model.copingB * 1000);
|
||
f.appendChild(el('figcaption', label, 'muted small'));
|
||
grid.appendChild(f);
|
||
}
|
||
wrap.appendChild(grid);
|
||
box.appendChild(wrap);
|
||
return;
|
||
}
|
||
wrap.appendChild(fig);
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
function renderRebar(): void {
|
||
const pier = activePier();
|
||
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
|
||
renderPierRows($<HTMLUListElement>('rebar-pier-items'), 'rebar');
|
||
const checked = state.piers.filter((p) => p.checked).length;
|
||
$('rebar-bulk-count').textContent = `선택 ${checked}기`;
|
||
$<HTMLButtonElement>('rebar-bulk-copy').disabled = !pier;
|
||
const tabs = $<HTMLUListElement>('rebar-tabs');
|
||
tabs.replaceChildren();
|
||
for (const m of REBAR_MEMBERS) {
|
||
const li = el('li');
|
||
const btn = el('button', m.label, 'member');
|
||
btn.type = 'button';
|
||
btn.setAttribute('aria-pressed', String(m.id === state.rebarMember));
|
||
btn.addEventListener('click', () => {
|
||
state.rebarMember = m.id;
|
||
state.rebarSelection = new Set();
|
||
renderAll();
|
||
});
|
||
li.appendChild(btn);
|
||
tabs.appendChild(li);
|
||
}
|
||
|
||
const box = $<HTMLDivElement>('rebar-table');
|
||
box.replaceChildren();
|
||
const issues = $<HTMLUListElement>('rebar-issues');
|
||
issues.replaceChildren();
|
||
if (!pier?.model) {
|
||
$('rebar-note').textContent = '교각을 생성하면 철근표가 나옵니다.';
|
||
return;
|
||
}
|
||
const { rows, warnings } = rebarSummary(pier.model, state.material, state.fckByMember);
|
||
$('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`;
|
||
if (state.rebarMember === 'bearing') renderBlockRebarSettings(box, pier);
|
||
if (state.rebarMember === 'coping' && rebar3d) renderCopingDrawings(box, pier);
|
||
if (state.rebarMember === 'coping') renderCopingSettings(box, pier);
|
||
if (state.rebarMember === 'column') renderColumnSettings(box, pier);
|
||
const shown = rows.filter((r) => r.member === state.rebarMember);
|
||
box.appendChild(rebarSelectBar(shown.map((r) => r.name)));
|
||
const table = el('table', undefined, 'grid-table rebar-table');
|
||
const head = el('thead');
|
||
const hr = el('tr');
|
||
for (const h of ['선택', '구분', '번호', '규격', '강도', '간격', '실간격', '위치', '방향', '형상', '길이 (mm)', '개수', '근거', '비고']) hr.appendChild(el('th', h));
|
||
head.appendChild(hr);
|
||
table.appendChild(head);
|
||
const body = el('tbody');
|
||
for (const r of shown) {
|
||
const tr = el('tr');
|
||
const nameCell = el('td', r.name, 'bar-no');
|
||
// Clicking a row selects that bar alone; clicking it again clears it (시안: 번호를 누르면 그 철근만 강조).
|
||
tr.addEventListener('click', () => {
|
||
state.rebarSelection = state.rebarSelection.size === 1 && state.rebarSelection.has(r.name) ? new Set() : new Set([r.name]);
|
||
renderAll();
|
||
});
|
||
if (state.rebarSelection.has(r.name)) tr.classList.add('hl');
|
||
const check = el('input');
|
||
check.type = 'checkbox';
|
||
check.checked = state.rebarSelection.has(r.name);
|
||
check.setAttribute('aria-label', `${r.name} 선택`);
|
||
// The checkbox changes the selection on its own; its click must not reach the row handler.
|
||
check.addEventListener('click', (ev) => ev.stopPropagation());
|
||
check.addEventListener('change', () => {
|
||
const next = new Set(state.rebarSelection);
|
||
if (check.checked) next.add(r.name);
|
||
else next.delete(r.name);
|
||
state.rebarSelection = next;
|
||
renderAll();
|
||
});
|
||
const pick = el('td');
|
||
pick.appendChild(check);
|
||
tr.append(
|
||
pick,
|
||
el('td', ROLE[r.name] ?? '—'),
|
||
nameCell,
|
||
el('td', r.size),
|
||
el('td', /^[DHS]\d/.test(r.size) ? `SD${fyOf(state.material, r.size)}` : '—'),
|
||
el('td', r.spacing),
|
||
el('td', r.actual),
|
||
el('td', r.position),
|
||
el('td', r.direction),
|
||
el('td', r.shape),
|
||
el('td', r.length === null ? (r.pieces ? '여러 길이' : '—') : String(Math.round(r.length * 10) / 10), undefined),
|
||
el('td', String(r.count)),
|
||
basisCell(r.basis),
|
||
el('td', r.note),
|
||
);
|
||
body.appendChild(tr);
|
||
}
|
||
table.appendChild(body);
|
||
box.appendChild(table);
|
||
|
||
// Alerts: [보기] goes to the member, [입력] goes to the 공통 설정 input (시안 ③ 알림).
|
||
for (const w of warnings) {
|
||
const li = el('li', `${w.code} ${w.message} `);
|
||
const btn = el('button', w.target === 'common' ? '입력' : '보기', 'btn small');
|
||
btn.type = 'button';
|
||
btn.addEventListener('click', () => {
|
||
if (w.target === 'common') {
|
||
$<HTMLInputElement>('mat-fck').focus();
|
||
$<HTMLInputElement>('mat-fck').scrollIntoView({ block: 'center' });
|
||
} else {
|
||
// [보기]: go to the member and highlight the bar the alert is about.
|
||
state.rebarMember = w.target;
|
||
state.rebarSelection = new Set(w.bar ? [w.bar] : []);
|
||
renderAll();
|
||
}
|
||
});
|
||
li.appendChild(btn);
|
||
issues.appendChild(li);
|
||
}
|
||
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
|
||
// Warning count on each member tab (시안: 부재 탭 배지).
|
||
const counts = (member: RebarRow['member']) => warnings.filter((w) => w.target === member).length;
|
||
for (const [i, m] of REBAR_MEMBERS.entries()) {
|
||
const badge = tabs.children[i]?.querySelector('button');
|
||
const n = counts(m.id);
|
||
if (badge && n > 0) badge.appendChild(el('span', String(n), 'badge warn'));
|
||
}
|
||
$('step3-status').textContent = `계산됨 · 경고 ${warnings.length}`;
|
||
renderMaterial();
|
||
// 전단철근 구간 표 (시안 ③ 코핑 탭): segments of C9 / C10 / C11 along the coping, in metres.
|
||
if (state.rebarMember === 'coping') {
|
||
const seg = shearSegments(pier.model.copingL * 1000, pier.model.columns.map((c) => c.s * 1000), pier.model.copingGeom);
|
||
const st = el('table', undefined, 'grid-table rebar-table');
|
||
const sh = el('thead');
|
||
const shr = el('tr');
|
||
for (const h of ['구간', '시작 (m)', '끝 (m)', '간격 (mm)', '개수', '번호']) shr.appendChild(el('th', h));
|
||
sh.appendChild(shr);
|
||
st.appendChild(sh);
|
||
const sb = el('tbody');
|
||
for (const g of seg) {
|
||
const tr = el('tr');
|
||
tr.append(
|
||
el('td', g.segment),
|
||
el('td', (g.from / 1000).toFixed(3)),
|
||
el('td', (g.to / 1000).toFixed(3)),
|
||
el('td', String(g.spacing)),
|
||
el('td', String(g.count)),
|
||
el('td', g.bar),
|
||
);
|
||
sb.appendChild(tr);
|
||
}
|
||
st.appendChild(sb);
|
||
box.appendChild(el('p', '전단철근 구간 (끝면 기준, 철근 위치의 처음과 끝)', 'lbl'));
|
||
box.appendChild(st);
|
||
}
|
||
// Table and 3D model point at each other: the focused bar is highlighted in both.
|
||
renderQuantityLink(box, rows);
|
||
rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate);
|
||
const focused = box.querySelector('tr.hl');
|
||
if (focused && state.rebarSelection.size) focused.scrollIntoView({ block: 'center' });
|
||
}
|
||
|
||
/** ③ keeps only a link to the 철근재료표 screen ④ (승인 2026-10-23). */
|
||
function renderQuantityLink(box: HTMLElement, rows: RebarRow[]): void {
|
||
const q = buildQuantity(rows, state.material);
|
||
const line = el('p', `철근재료표 · 합계 ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote');
|
||
const link = el('button', '④ 재료표에서 보기', 'btn small');
|
||
link.type = 'button';
|
||
link.addEventListener('click', () => setStage('quantity'));
|
||
box.append(line, link);
|
||
}
|
||
|
||
/** ④ 재료표: the 철근재료표 of each target pier, bars by 강종 → 직경 → 번호, with subtotals and the totals. */
|
||
function quantityTargets(): Pier[] {
|
||
const checked = state.piers.filter((p) => p.checked && p.model);
|
||
if (checked.length) return checked;
|
||
const active = activePier();
|
||
return active?.model ? [active] : [];
|
||
}
|
||
|
||
/** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */
|
||
function lengthCell(l: QuantityLine): HTMLTableCellElement {
|
||
if (l.length !== null) return el('td', String(Math.round(l.length * 10) / 10));
|
||
const cell = el('td', `여러 길이 (${l.pieces.length}종)`);
|
||
cell.title = l.pieces.map((p) => `${Math.round(p.length)} × ${p.count}`).join(' · ');
|
||
return cell;
|
||
}
|
||
|
||
function quantityTable(q: Quantity): HTMLTableElement {
|
||
const table = el('table', undefined, 'grid-table rebar-table');
|
||
const head = el('thead');
|
||
const hr = el('tr');
|
||
for (const h of ['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']) hr.appendChild(el('th', h));
|
||
head.appendChild(hr);
|
||
table.appendChild(head);
|
||
const body = el('tbody');
|
||
for (const g of q.groups) {
|
||
for (const d of g.diameters) {
|
||
for (const l of d.lines) {
|
||
body.appendChild(el('tr')).append(
|
||
el('td', `SD${g.grade}`), el('td', l.name, 'bar-no'), el('td', l.shape), el('td', l.size), el('td', String(l.count)),
|
||
lengthCell(l), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)),
|
||
);
|
||
}
|
||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1)));
|
||
}
|
||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)));
|
||
}
|
||
table.appendChild(body);
|
||
return table;
|
||
}
|
||
|
||
function renderQuantityScreen(): void {
|
||
const body = $<HTMLDivElement>('quantity-body');
|
||
body.replaceChildren();
|
||
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
|
||
const targets = quantityTargets();
|
||
$('step4-status').textContent = targets.length ? `${targets.length}기` : '예정';
|
||
$('quantity-note').textContent = targets.length
|
||
? `${targets.length}기 · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 체크한 교각 기준이고, 체크가 없으면 현재 교각입니다.`
|
||
: '계산된 교각이 없습니다. 교각을 생성하세요.';
|
||
let kg = 0;
|
||
let kgWithWaste = 0;
|
||
for (const pier of targets) {
|
||
if (!pier.model) continue;
|
||
const q = buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material);
|
||
kg += q.kg;
|
||
kgWithWaste += q.kgWithWaste;
|
||
body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`));
|
||
if (q.missing.length) body.appendChild(el('p', `길이 미산정 ${q.missing.length}개는 합계에서 뺐습니다: ${q.missing.join(', ')}`, 'muted small'));
|
||
body.appendChild(quantityTable(q));
|
||
body.appendChild(el('p', `합계 ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote'));
|
||
}
|
||
if (targets.length > 1) {
|
||
body.appendChild(el('p', `전체 ${targets.length}기 합계 ${kg.toFixed(1)} kg · 할증 포함 ${kgWithWaste.toFixed(1)} kg (${(kgWithWaste / 1000).toFixed(3)} ton)`, 'lbl'));
|
||
}
|
||
}
|
||
|
||
/** Excel: one sheet per pier and a 합계 sheet (승인 2026-10-23). */
|
||
function exportQuantityWorkbook(): void {
|
||
const targets = quantityTargets();
|
||
if (!targets.length) return;
|
||
const wb = XLSX.utils.book_new();
|
||
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton']];
|
||
for (const pier of targets) {
|
||
if (!pier.model) continue;
|
||
const q = buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material);
|
||
const sheet = pier.input.name.replace(/[[\]:*?/\\]/g, '_').slice(0, 31);
|
||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(quantityRows(q)), sheet);
|
||
summary.push([pier.input.name, Math.round(q.kg * 10) / 10, Math.round(q.kgWithWaste * 10) / 10, Math.round(q.ton * 1000) / 1000]);
|
||
}
|
||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), '합계');
|
||
XLSX.writeFile(wb, `${state.project.bridge || '교량'}_철근재료표.xlsx`);
|
||
}
|
||
|
||
$<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook);
|
||
$<HTMLButtonElement>('quantity-print').addEventListener('click', () => window.print());
|
||
|
||
/** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */
|
||
function rebarSelectBar(names: string[]): HTMLDivElement {
|
||
const bar = el('div', undefined, 'rebar-select');
|
||
const picked = names.filter((n) => state.rebarSelection.has(n)).length;
|
||
bar.appendChild(el('span', `선택 ${picked} / ${names.length}`, 'muted small'));
|
||
const all = el('button', picked === names.length && names.length > 0 ? '전체 해제' : '전체 선택', 'btn small');
|
||
all.type = 'button';
|
||
all.addEventListener('click', () => {
|
||
state.rebarSelection = picked === names.length ? new Set() : new Set(names);
|
||
renderAll();
|
||
});
|
||
const iso = el('button', '선택만 보기', 'btn small');
|
||
iso.type = 'button';
|
||
iso.setAttribute('aria-pressed', String(state.rebarIsolate));
|
||
iso.addEventListener('click', () => {
|
||
state.rebarIsolate = !state.rebarIsolate;
|
||
renderAll();
|
||
});
|
||
bar.append(all, iso);
|
||
return bar;
|
||
}
|
||
|
||
/** 구분 (role) of each bar, as in the 시안 ③ table. */
|
||
const ROLE: Record<string, string> = {
|
||
S1: '받침블럭', S2: '받침블럭',
|
||
C1: '주철근', C2: '주철근', C3: '주철근', C4: '주철근', C5: '주철근', C6: '주철근', C7: '주철근', C8: '주철근',
|
||
C9: '전단·띠', C10: '전단·띠', C11: '전단·띠', C12: '측면 수평', C13: '측면 수평', C14: '직각 수평', C15: '계단부', C16: '계단부',
|
||
P1: '주철근', P2: '주철근', '주철근 커플러': '조립', '띠철근 커플러': '조립', 커플러: '조립', P3: '띠철근', P5: '띠철근', P4: '띠철근', P6: '크로스타이',
|
||
B1: '주철근', B2: '주철근', B3: '주철근', B4: '주철근', B5: '주철근', B6: '주철근', B7: '주철근', B8: '주철근',
|
||
'B10-1': '측면 수평', 'B10-2': '측면 수평', B11: '조립 수평', B12: '조립 수평', B9: '전단·띠',
|
||
};
|
||
|
||
/** 근거 badge (시안 ③): 입력 / 기본값 / 계산 / 가정. */
|
||
function basisCell(b: RebarRow['basis']): HTMLElement {
|
||
const label = { input: '입력', default: '기본값', calc: '계산', assumed: '가정' }[b];
|
||
return el('td', label, `basis basis-${b}`);
|
||
}
|
||
|
||
/** ③ 공통 설정 inputs. Changes apply on 'change' and regenerate the rebar screen. */
|
||
function renderMaterial(): void {
|
||
const m = state.material;
|
||
const assumed = isAssumedMaterial(m);
|
||
$<HTMLInputElement>('mat-fck').value = String(m.fck);
|
||
for (const k of ['d', 'h', 's'] as const) {
|
||
$<HTMLSelectElement>(`mat-grade-${k}`).value = String(m.grade[k.toUpperCase() as GradeKey]);
|
||
}
|
||
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
|
||
$<HTMLInputElement>(`bend-${k}`).value = String(m.bend[k]);
|
||
}
|
||
$<HTMLInputElement>('mat-waste').value = String(m.waste);
|
||
$('mat-note').textContent = assumed ? 'fck 가정값 (I02): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.';
|
||
}
|
||
|
||
/** Sets or clears one member's fck. An empty value clears it, so the member uses the 공통 fck again. */
|
||
function setMemberFck(member: FckMember, raw: string): void {
|
||
const next = { ...state.fckByMember };
|
||
if (raw.trim() === '') {
|
||
delete next[member];
|
||
} else {
|
||
const value = Number(raw);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
setError('부재별 fck는 0보다 큰 숫자여야 합니다.');
|
||
renderPlan();
|
||
return;
|
||
}
|
||
next[member] = value;
|
||
}
|
||
state.fckByMember = next;
|
||
setupScene();
|
||
renderAll();
|
||
}
|
||
|
||
/** fck row on a ② card for a member that has its own fck: value, the 공통 value beside it, and a revert when set. */
|
||
function renderMemberFck(box: HTMLDivElement, member: FckMember): void {
|
||
const own = state.fckByMember[member];
|
||
// Not the orange "changed" style: fck changes only the rebar, so it is not a pending shape change (변경 n건).
|
||
const row = el('div', undefined, 'edit-row');
|
||
const id = `member-fck-${member}`;
|
||
const label = el('label', 'fck (MPa)');
|
||
label.htmlFor = id;
|
||
const input = el('input');
|
||
input.id = id;
|
||
input.type = 'number';
|
||
input.step = 'any';
|
||
input.value = String(own ?? state.material.fck);
|
||
input.addEventListener('change', () => setMemberFck(member, input.value));
|
||
const note = own === undefined ? `공통 ${state.material.fck}` : `공통 ${state.material.fck} · 배근에만 반영`;
|
||
row.append(label, input, el('span', note, 'muted small'));
|
||
if (own !== undefined) {
|
||
const revert = el('button', '되돌리기', 'btn small');
|
||
revert.type = 'button';
|
||
revert.addEventListener('click', () => setMemberFck(member, ''));
|
||
row.appendChild(revert);
|
||
}
|
||
box.appendChild(row);
|
||
}
|
||
|
||
/** 재료표 할증 (%) on 공통 설정. Zero or more. */
|
||
function setWaste(raw: string): void {
|
||
const value = Number(raw);
|
||
if (!Number.isFinite(value) || value < 0) {
|
||
setError('재료표 할증은 0 이상의 숫자여야 합니다.');
|
||
renderMaterial();
|
||
return;
|
||
}
|
||
state.material = { ...state.material, waste: value };
|
||
renderAll();
|
||
}
|
||
|
||
/** Bend factor (× diameter) input on 공통 설정. Must be a positive number. */
|
||
function setBend(key: keyof BendFactors, raw: string): void {
|
||
const value = Number(raw);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
setError('굽힘 내면반경 배수는 0보다 큰 숫자여야 합니다.');
|
||
renderMaterial();
|
||
return;
|
||
}
|
||
state.material = { ...state.material, bend: { ...state.material.bend, [key]: value } };
|
||
setupScene();
|
||
renderAll();
|
||
}
|
||
|
||
function setMaterial(key: 'fck' | GradeKey, raw: string): void {
|
||
const value = Number(raw);
|
||
const valid = key === 'fck' ? Number.isFinite(value) && value > 0 : (GRADE_CHOICES as readonly number[]).includes(value);
|
||
if (!valid) {
|
||
setError(key === 'fck' ? '공통 설정 fck는 0보다 큰 숫자여야 합니다.' : '철근 강도는 SD300 · SD400 · SD500 중 하나여야 합니다.');
|
||
renderMaterial();
|
||
return;
|
||
}
|
||
state.material = key === 'fck' ? { ...state.material, fck: value } : { ...state.material, grade: { ...state.material.grade, [key]: value } };
|
||
// The 3D bars depend on the material too (their lengths), so the scene is rebuilt with it.
|
||
setupScene();
|
||
renderAll();
|
||
}
|
||
|
||
/** ② 교량받침 reference (1단계, 읽기 전용): the drawing's ELEVATION and 솔플레이트 tables, not used in the model (Q16). */
|
||
/** Rebuilds every pier from its draft, so that a change of basis or drawing values reaches the model. */
|
||
function regenerateAllPiers(): void {
|
||
state.piers = state.piers.map((p) => regenerateEntry(p));
|
||
setupScene();
|
||
}
|
||
|
||
/** ② 받침 기준 (Q16, 승인 2026-10-23): project-wide basis, and the drawing EL.X / h1 / h2 for this pier. */
|
||
function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void {
|
||
wrap.appendChild(el('p', '받침 기준 (프로젝트 전체 · 기본: 엑셀 z)', 'lbl'));
|
||
const sel = el('select');
|
||
sel.id = 'bearing-basis';
|
||
for (const [value, label] of [['excel', '엑셀 z (기본)'], ['drawing', '도면 EL.X']] as const) {
|
||
const o = el('option', label);
|
||
o.value = value;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.value = bearingBasis;
|
||
sel.addEventListener('change', () => {
|
||
bearingBasis = sel.value as BearingBasis;
|
||
regenerateAllPiers();
|
||
renderAll();
|
||
});
|
||
wrap.appendChild(sel);
|
||
|
||
const name = pier.input.name;
|
||
const current = new Map((bearingDrawing[name] ?? []).map((d) => [d.no, d]));
|
||
const table = el('table', undefined, 'grid-table rebar-table');
|
||
const head = el('thead');
|
||
const hr = el('tr');
|
||
for (const h of ['No', '열', 'EL.X (m)', 'h1 (mm)', 'h2 (mm)', '코핑 상면 차이 (mm)']) hr.appendChild(el('th', h));
|
||
head.appendChild(hr);
|
||
table.appendChild(head);
|
||
const body = el('tbody');
|
||
// The reference is the Excel-basis model: the drawing EL.X − h1 − h2 must reproduce its coping tops within 5 mm.
|
||
const checks = bearingBasis === 'drawing' && bearingDrawing[name]?.length ? drawingCopingCheck(buildPier(pier.input), bearingDrawing[name]) : [];
|
||
const checkOf = new Map(checks.map((c) => [c.no, c]));
|
||
const readTable = (): DrawingBearing[] => {
|
||
const rows: DrawingBearing[] = [];
|
||
body.querySelectorAll<HTMLTableRowElement>('tr').forEach((tr) => {
|
||
const inputs = tr.querySelectorAll<HTMLInputElement>('input');
|
||
const [elx, h1, h2] = Array.from(inputs).map((i) => Number(i.value));
|
||
const no = Number(tr.dataset.no);
|
||
if ([elx, h1, h2].every((v) => Number.isFinite(v) && v > 0)) rows.push({ no, elx, h1, h2 });
|
||
});
|
||
return rows;
|
||
};
|
||
const commit = () => {
|
||
bearingDrawing = { ...bearingDrawing, [name]: readTable() };
|
||
regenerateAllPiers();
|
||
renderAll();
|
||
};
|
||
for (const b of [...pier.input.bearings].sort((x, y) => x.no - y.no)) {
|
||
const d = current.get(b.no);
|
||
const tr = el('tr');
|
||
tr.dataset.no = String(b.no);
|
||
const cells: (string | HTMLElement)[] = [String(b.no), b.row];
|
||
for (const [value, step] of [[d?.elx, '0.001'], [d?.h1, '1'], [d?.h2, '1']] as const) {
|
||
const input = el('input');
|
||
input.type = 'number';
|
||
input.step = step;
|
||
input.value = value === undefined ? '' : String(value);
|
||
input.addEventListener('change', commit);
|
||
cells.push(input);
|
||
}
|
||
const c = checkOf.get(b.no);
|
||
cells.push(c === undefined ? '—' : `${c.diffMm.toFixed(1)}${c.ok ? '' : ` (허용 ${DRAWING_TOL_MM} 초과)`}`);
|
||
tr.append(
|
||
...cells.map((x) => {
|
||
const td = el('td');
|
||
if (typeof x === 'string') td.textContent = x;
|
||
else td.appendChild(x);
|
||
return td;
|
||
}),
|
||
);
|
||
body.appendChild(tr);
|
||
}
|
||
table.appendChild(body);
|
||
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
|
||
|
||
// 도면 불러오기 (승인 2026-10-23): a sheet with No, EL.X, h1, h2 (xlsx or csv) fills this pier's drawing values.
|
||
const importInput = el('input');
|
||
importInput.type = 'file';
|
||
importInput.accept = '.xlsx,.xls,.csv,.dxf';
|
||
importInput.id = 'drawing-import';
|
||
importInput.addEventListener('change', async () => {
|
||
const file = importInput.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
// A DXF drawing: the 교량받침 ELEVATION TABLE is read by position. Otherwise a sheet with No, EL.X, h1, h2.
|
||
let drawingRows: DrawingBearing[];
|
||
if (file.name.toLowerCase().endsWith('.dxf')) {
|
||
const texts = readDxfTexts(await file.text());
|
||
const r = parseBearingElevationTable(texts);
|
||
if (r.errors.length) {
|
||
setError(`도면 불러오기 실패: ${r.errors.slice(0, 3).join(' ')}`);
|
||
return;
|
||
}
|
||
// The solplate table gives S per bearing (승인 2026-10-23); a missing S is reported, not guessed.
|
||
const sp = parseSolplateTable(texts);
|
||
const sByNo = new Map(sp.rows.map((x) => [x.no, x.S]));
|
||
drawingRows = r.drawing.map((d) => ({ ...d, S: sByNo.get(d.no) }));
|
||
if (sp.errors.length) setStatus(`솔플레이트 값 확인: ${sp.errors.slice(0, 2).join(' ')}`);
|
||
} else {
|
||
const wb = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, blankrows: false, defval: '' });
|
||
const r = parseDrawingRows(rows);
|
||
if (r.errors.length) {
|
||
setError(`도면 불러오기 실패: ${r.errors.slice(0, 3).join(' ')}`);
|
||
return;
|
||
}
|
||
drawingRows = r.rows;
|
||
}
|
||
bearingDrawing = { ...bearingDrawing, [name]: drawingRows };
|
||
regenerateAllPiers();
|
||
renderAll();
|
||
setStatus(`${file.name}에서 받침 ${drawingRows.length}개의 도면 값을 불러왔습니다.`);
|
||
} catch (err) {
|
||
setError(`도면 파일을 읽지 못했습니다: ${(err as Error).message}`);
|
||
} finally {
|
||
importInput.value = '';
|
||
}
|
||
});
|
||
const importLabel = el('label', '도면 파일 불러오기 (xlsx·csv)');
|
||
importLabel.appendChild(importInput);
|
||
wrap.appendChild(importLabel);
|
||
const fill = el('button', '도면값 채우기 (참조 값)', 'btn small');
|
||
fill.type = 'button';
|
||
fill.addEventListener('click', () => {
|
||
// The reference values include A, B (plan) and S (solplate slope), so the solplate can be drawn (승인 2026-10-23).
|
||
bearingDrawing = { ...bearingDrawing, [name]: BEARING_ELEVATION_REF.map((r, i) => ({ no: r.no, elx: r.elx, h1: r.h1, h2: r.h2, A: r.A, B: r.B, S: SOLPLATE_REF[i].s })) };
|
||
regenerateAllPiers();
|
||
renderAll();
|
||
});
|
||
wrap.appendChild(fill);
|
||
const rowsNow = bearingDrawing[name] ?? [];
|
||
const missingPlan = pier.input.bearings.filter((b) => !rowsNow.some((d) => d.no === b.no && d.A !== undefined && d.B !== undefined && d.S !== undefined)).length;
|
||
if (missingPlan > 0) {
|
||
wrap.appendChild(el('p', `솔플레이트 값(A, B, S)이 없는 받침 ${missingPlan}개는 솔플레이트를 그리지 않습니다. 도면을 불러오거나 "도면값 채우기"를 누르세요.`, 'warn-text'));
|
||
}
|
||
if (bearingBasis === 'drawing' && !bearingDrawing[name]?.length) {
|
||
wrap.appendChild(el('p', '도면 값이 없어서 엑셀 z로 계산 중입니다.', 'warn-text'));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 받침 배치 (승인 2026-10-26, read-only): C4·C5 from the model (coping edge ↔ bearing centre, 교각축 방향) and the
|
||
* eccentricity of each front–back pair (일체 allowed up to 100 mm, otherwise 분리 first).
|
||
*/
|
||
/** 상세 보기 pair shown in the plan, and whether the drawing values are overlaid (screen state only, 승인 2026-10-26). */
|
||
let bearingPairIndex = 0;
|
||
let bearingOverlayOn = true;
|
||
/** ③ 받침블럭 view: the C–C section or the plan (승인 2026-10-26). */
|
||
let bearingRebarView: 'section' | 'plan' = 'section';
|
||
|
||
/**
|
||
* Drawing values of one bearing for the overlays (승인 2026-10-26): the imported drawing row where it has the value
|
||
* (elx, h1, h2, A, B), otherwise the reference table (h3, h4, E).
|
||
*/
|
||
function drawingRefFor(pier: Pier, no: number): BearingDrawingRef | undefined {
|
||
const r = BEARING_ELEVATION_REF.find((x) => x.no === no);
|
||
if (!r) return undefined;
|
||
const d = (bearingDrawing[pier.input.name] ?? []).find((x) => x.no === no);
|
||
const A = d?.A ?? r.A;
|
||
const B = d?.B ?? r.B;
|
||
return { elx: d?.elx ?? r.elx, h1: d?.h1 ?? r.h1, h2: d?.h2 ?? r.h2, h3: r.h3, h4: r.h4, E: r.E, A, B };
|
||
}
|
||
|
||
/** Drawing A, B of the front and back bearings of a pair, for the plan overlay. */
|
||
function overlayFor(pier: Pier, frontNo: number, backNo: number): BearingPlanOverlay | undefined {
|
||
const f = drawingRefFor(pier, frontNo);
|
||
const b = drawingRefFor(pier, backNo);
|
||
return f && b ? { front: { A: f.A, B: f.B }, back: { A: b.A, B: b.B } } : undefined;
|
||
}
|
||
|
||
/** Drawing solplate values of one bearing (reference table), for the solplate detail overlay. */
|
||
function solplateRefFor(no: number): SolplateDrawingRef | undefined {
|
||
const r = SOLPLATE_REF.find((x) => x.no === no);
|
||
return r ? { L: r.L, W: r.W, T1: r.T1, T2: r.T2, S: r.s } : undefined;
|
||
}
|
||
|
||
function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void {
|
||
if (!pier.model) return;
|
||
// 상세 보기 (승인 2026-10-26): pair chips (①·⑥ …) and the plan with the drawing values overlaid as dashed outlines.
|
||
const pairList = pairEccentricities(pier.model.blocks);
|
||
if (bearingPairIndex >= pairList.length) bearingPairIndex = 0;
|
||
wrap.appendChild(el('p', '상세 보기 (전열 · 후열 쌍)', 'lbl'));
|
||
const chips = el('div', undefined, 'opts');
|
||
pairList.forEach((p, i) => {
|
||
const chip = el('button', `${PAIR_CHIP_LABELS[p.frontNo - 1] ?? p.frontNo}·${PAIR_CHIP_LABELS[p.backNo - 1] ?? p.backNo}`, 'btn small');
|
||
chip.type = 'button';
|
||
chip.setAttribute('aria-pressed', String(i === bearingPairIndex));
|
||
chip.addEventListener('click', () => {
|
||
bearingPairIndex = i;
|
||
renderAll();
|
||
});
|
||
chips.appendChild(chip);
|
||
});
|
||
wrap.appendChild(chips);
|
||
const overlayCheck = el('input');
|
||
overlayCheck.type = 'checkbox';
|
||
overlayCheck.checked = bearingOverlayOn;
|
||
overlayCheck.id = 'bearing-overlay';
|
||
overlayCheck.addEventListener('change', () => {
|
||
bearingOverlayOn = overlayCheck.checked;
|
||
renderAll();
|
||
});
|
||
const overlayLabel = el('label', ' 도면값 겹쳐 보기 (점선)');
|
||
overlayLabel.prepend(overlayCheck);
|
||
wrap.appendChild(overlayLabel);
|
||
const current = pairList[bearingPairIndex];
|
||
if (current) {
|
||
const fig = el('figure', undefined, 'ground-fig');
|
||
const overlay = bearingOverlayOn ? overlayFor(pier, current.frontNo, current.backNo) : undefined;
|
||
fig.innerHTML = bearingPlanSvg(pier.model, bearingPairIndex, overlay);
|
||
fig.appendChild(el('figcaption', '평면 · 실선 = 모델(솔플레이트, 블록 윤곽) · 점선 = 도면값 · C4·C5는 교축 방향, 받침 중심에서 블록 바깥 연단까지', 'muted small'));
|
||
wrap.appendChild(fig);
|
||
|
||
// 단면 (elevation): the blocks, mortar, bearing body and solplates; the drawing heights dashed.
|
||
const section = el('figure', undefined, 'ground-fig');
|
||
const refs = bearingOverlayOn ? { front: drawingRefFor(pier, current.frontNo), back: drawingRefFor(pier, current.backNo) } : undefined;
|
||
section.innerHTML = bearingSectionSvg(pier.model, bearingPairIndex, refs);
|
||
section.appendChild(el('figcaption', '단면 · EL.X는 모르타르 상단, E = h1 + h2 + h3 + h4 · 점선 = 도면 높이', 'muted small'));
|
||
wrap.appendChild(section);
|
||
|
||
// 솔플레이트 상세 for the two bearings of the pair (plan and taper, drawing dashed).
|
||
const solGrid = el('div', undefined, 'footing-grid');
|
||
for (const no of [current.frontNo, current.backNo]) {
|
||
const block = pier.model.blocks.find((b) => b.no === no)!;
|
||
const fig2 = el('figure', undefined, 'footing-fig');
|
||
fig2.innerHTML = solplateDetailSvg(block, bearingOverlayOn ? solplateRefFor(no) : undefined);
|
||
fig2.appendChild(el('figcaption', `솔플레이트 상세 · ${no}번 · 두께 과장 · 점선 = 도면값`, 'muted small'));
|
||
solGrid.appendChild(fig2);
|
||
}
|
||
wrap.appendChild(solGrid);
|
||
}
|
||
wrap.appendChild(el('p', `배치 규칙 (모델값 · 편심 한계 ${PAIR_ECCENTRICITY_LIMIT_MM} mm)`, 'lbl'));
|
||
const edges = bearingEdgeDistances(pier.model);
|
||
const table = el('table', undefined, 'grid-table rebar-table');
|
||
const head = el('thead');
|
||
const hr = el('tr');
|
||
for (const h of ['받침', '열', 'C4 (교각축 · 받침 ↔ 블록 연단, mm)', 'C5 (교각축 · 받침 ↔ 블록 연단, mm)']) hr.appendChild(el('th', h));
|
||
head.appendChild(hr);
|
||
table.appendChild(head);
|
||
const body = el('tbody');
|
||
for (const e of edges) {
|
||
const tr = el('tr');
|
||
for (const v of [String(e.no), e.row, e.leftMm.toFixed(0), e.rightMm.toFixed(0)]) tr.appendChild(el('td', v));
|
||
body.appendChild(tr);
|
||
}
|
||
table.appendChild(body);
|
||
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
|
||
const pairs = pairEccentricities(pier.model.blocks);
|
||
const forced = pairs.filter((p) => !p.allowed).length;
|
||
wrap.appendChild(el('p', `쌍 편심 (전열 k · 후열 k, 교각축 방향): 최대 ${Math.max(...pairs.map((p) => p.eccentricityMm)).toFixed(0)} mm · ${forced === 0 ? '모든 쌍 일체 가능' : `${forced}쌍은 편심 초과 → 분리 우선`}`, 'muted small'));
|
||
}
|
||
|
||
/** Text of one tracked value in the 변경 list. */
|
||
function valueText(key: EditableKey, v: unknown): string {
|
||
if (v === undefined || v === null) return '없음';
|
||
if (key === 'strataRows') {
|
||
const rows = v as StrataRow[];
|
||
return rows.length ? rows.map((r) => `${r.name} ${r.bottomEl}`).join(' / ') : '없음';
|
||
}
|
||
if (typeof v === 'boolean') return v ? '예' : '아니오';
|
||
if (typeof v === 'object') return JSON.stringify(v);
|
||
return String(v);
|
||
}
|
||
|
||
/** Sets 받침 배치 (분리 / 일체) in the draft (applied by 다시 생성). */
|
||
function editBearingLayout(name: string, value: '분리' | '일체'): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
p.draft = { ...p.draft, bearingLayout: value };
|
||
renderAll();
|
||
}
|
||
|
||
/** Sets one 지반·지층 input in the draft (applied to the model by 다시 생성). */
|
||
function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl', value: unknown): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
const numeric = key !== 'groundPosition' && key !== 'groundWaterOn';
|
||
if (numeric && value !== undefined && !Number.isFinite(value)) {
|
||
setError(`${FIELD_LABELS[key]}은(는) 숫자로 입력하세요. 값을 되돌렸습니다.`);
|
||
renderAll();
|
||
return;
|
||
}
|
||
p.draft = { ...p.draft, [key]: value };
|
||
renderAll();
|
||
}
|
||
|
||
function setStrataRows(name: string, rows: StrataRow[]): void {
|
||
const p = state.piers.find((q) => q.input.name === name);
|
||
if (!p) return;
|
||
p.draft = { ...p.draft, strataRows: rows };
|
||
renderAll();
|
||
}
|
||
|
||
/** 일괄 적용: the source's 위치 and 지하수위 go to the checked piers, which are then regenerated. */
|
||
function applyGroundPositionTo(source: Pier, targets: Pier[]): void {
|
||
for (const target of targets) {
|
||
target.draft = applyCopy(target.draft, planCopy(source.draft, target.draft, ['ground-position']));
|
||
}
|
||
regenerateAllPiers();
|
||
renderAll();
|
||
setStatus(`${targets.length}기에 위치와 지하수위를 적용했습니다.`);
|
||
}
|
||
|
||
/**
|
||
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
|
||
* The ground model import and the 지반고 apply stay at the bottom, as before.
|
||
*/
|
||
function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
||
const name = pier.input.name;
|
||
const d = pier.draft;
|
||
const wrap = el('div', undefined, 'ground-model');
|
||
|
||
// 위치 is the 형상 옵션 of this member (the chips above the card); only the default is selectable until Q6.
|
||
|
||
const position = d.groundPosition ?? '육상';
|
||
if (position === '하상') {
|
||
// 하상 (승인 2026-10-26): the 설계홍수위 DHWL and the 세굴 깊이 replace the groundwater; the groundwater value is kept.
|
||
wrap.appendChild(el('p', '설계홍수위 DHWL · 세굴', 'lbl'));
|
||
const scourRow = el('div', undefined, 'ground-water');
|
||
scourRow.style.display = 'flex';
|
||
scourRow.style.flexWrap = 'wrap';
|
||
scourRow.style.alignItems = 'center';
|
||
scourRow.style.gap = '8px';
|
||
for (const [key, label, value] of [['floodLevelEl', '설계홍수위 EL (m)', d.floodLevelEl], ['scourDepthM', '세굴 깊이 (m)', d.scourDepthM]] as const) {
|
||
const input = el('input');
|
||
input.type = 'number';
|
||
input.step = 'any';
|
||
input.placeholder = label;
|
||
input.value = value === undefined ? '' : String(value);
|
||
input.addEventListener('change', () => editGround(name, key, input.value === '' ? undefined : Number(input.value)));
|
||
scourRow.append(el('span', label, 'muted small'), input);
|
||
}
|
||
wrap.appendChild(scourRow);
|
||
} else if (position === '해상') {
|
||
// 해상 (승인 2026-10-26, 기준면 MSL): 고조위·저조위와 세굴. 파랑과 선박 하중은 이번 범위에서 제외.
|
||
wrap.appendChild(el('p', `해상 · 기준면 ${SEA_DATUM} · 지반고 칸은 해저면 EL`, 'lbl'));
|
||
const seaRow = el('div', undefined, 'ground-water');
|
||
seaRow.style.display = 'flex';
|
||
seaRow.style.flexWrap = 'wrap';
|
||
seaRow.style.alignItems = 'center';
|
||
seaRow.style.gap = '8px';
|
||
for (const [key, label, value] of [['highWaterEl', '고조위 HWL (m)', d.highWaterEl], ['lowWaterEl', '저조위 LWL (m)', d.lowWaterEl], ['scourDepthM', '세굴 깊이 (m)', d.scourDepthM]] as const) {
|
||
const input = el('input');
|
||
input.type = 'number';
|
||
input.step = 'any';
|
||
input.placeholder = label;
|
||
input.value = value === undefined ? '' : String(value);
|
||
input.addEventListener('change', () => editGround(name, key, input.value === '' ? undefined : Number(input.value)));
|
||
seaRow.append(el('span', label, 'muted small'), input);
|
||
}
|
||
wrap.appendChild(seaRow);
|
||
wrap.appendChild(el('p', `부식 여유 기준 ${CORROSION_ALLOWANCE_MM} mm (확인 필요) · 파랑과 선박 하중은 이번 범위에서 제외`, 'muted small'));
|
||
} else {
|
||
wrap.appendChild(el('p', '지하수위', 'lbl'));
|
||
const water = el('div', undefined, 'ground-water');
|
||
water.style.display = 'flex';
|
||
water.style.flexWrap = 'wrap';
|
||
water.style.alignItems = 'center';
|
||
water.style.gap = '8px';
|
||
const check = el('input');
|
||
check.type = 'checkbox';
|
||
check.checked = d.groundWaterOn === true;
|
||
check.id = 'ground-water-on';
|
||
check.addEventListener('change', () => editGround(name, 'groundWaterOn', check.checked));
|
||
const checkLabel = el('label', ' 지하수위 고려');
|
||
checkLabel.prepend(check);
|
||
const level = el('input');
|
||
level.type = 'number';
|
||
level.step = 'any';
|
||
level.id = 'ground-water-el';
|
||
level.placeholder = '지하수위 EL (m)';
|
||
level.value = d.groundWaterEl === undefined ? '' : String(d.groundWaterEl);
|
||
level.disabled = !check.checked;
|
||
level.addEventListener('change', () => editGround(name, 'groundWaterEl', level.value === '' ? undefined : Number(level.value)));
|
||
water.append(checkLabel, level, el('span', 'm', 'muted small'));
|
||
wrap.appendChild(water);
|
||
}
|
||
|
||
// 지층 행 (승인 2026-10-26): one row per layer, from the top down; the EL is the layer's bottom EL.
|
||
const rows = d.strataRows ?? [];
|
||
wrap.appendChild(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${d.groundEl.toFixed(3)} 아래)`, 'lbl'));
|
||
const table = el('table', undefined, 'grid-table rebar-table');
|
||
const head = el('thead');
|
||
const hr = el('tr');
|
||
for (const h of ['지층', '하단 EL (m)', 'N값 (타수 · 타수/관입량 mm)', '']) hr.appendChild(el('th', h));
|
||
head.appendChild(hr);
|
||
table.appendChild(head);
|
||
const body = el('tbody');
|
||
rows.forEach((row, i) => {
|
||
const tr = el('tr');
|
||
const select = el('select');
|
||
for (const s of STRATA_NAMES) {
|
||
const o = el('option', s);
|
||
o.value = s;
|
||
select.appendChild(o);
|
||
}
|
||
if (!(STRATA_NAMES as readonly string[]).includes(row.name)) {
|
||
const o = el('option', row.name);
|
||
o.value = row.name;
|
||
select.appendChild(o);
|
||
}
|
||
select.value = row.name;
|
||
select.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, name: select.value } : r))));
|
||
const bottomInput = el('input');
|
||
bottomInput.type = 'number';
|
||
bottomInput.step = '0.001';
|
||
bottomInput.value = String(row.bottomEl);
|
||
bottomInput.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r))));
|
||
// N값 as text: a blow count or "타수/관입량 mm" (승인 2026-10-26). Empty means not known yet.
|
||
const nInput = el('input');
|
||
nInput.type = 'text';
|
||
nInput.placeholder = '예: 23 · 50/5';
|
||
nInput.value = row.nValue ?? '';
|
||
nInput.addEventListener('change', () => {
|
||
const v = nInput.value.trim();
|
||
setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, nValue: v === '' ? undefined : v } : r)));
|
||
});
|
||
if (parseNValue(row.nValue).kind === 'invalid') nInput.classList.add('warn-text');
|
||
const del = el('button', '삭제', 'btn small');
|
||
del.type = 'button';
|
||
del.addEventListener('click', () => setStrataRows(name, rows.filter((_, j) => j !== i)));
|
||
tr.append(...[select, bottomInput, nInput, del].map((x) => {
|
||
const td = el('td');
|
||
td.appendChild(x);
|
||
return td;
|
||
}));
|
||
body.appendChild(tr);
|
||
});
|
||
table.appendChild(body);
|
||
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
|
||
const add = el('button', '지층 행 추가', 'btn small');
|
||
add.type = 'button';
|
||
add.addEventListener('click', () => {
|
||
const last = rows[rows.length - 1];
|
||
const next = last ? (STRATA_NAMES as readonly string[])[Math.min((STRATA_NAMES as readonly string[]).indexOf(last.name) + 1, STRATA_NAMES.length - 1)] : STRATA_NAMES[0];
|
||
setStrataRows(name, [...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? d.groundEl) - 2).toFixed(3)) }]);
|
||
});
|
||
wrap.appendChild(add);
|
||
// 지반 모델의 지층 기둥 (Python, 시추 자료가 있을 때): one click fills the rows (승인 2026-10-26).
|
||
if (groundModel) {
|
||
const modelRows = strataRowsFor(groundModel, name);
|
||
if (modelRows) {
|
||
const fromModel = el('button', `지반 모델의 지층 ${modelRows.length}개 불러오기`, 'btn small');
|
||
fromModel.type = 'button';
|
||
fromModel.addEventListener('click', () => setStrataRows(name, modelRows.map((r) => ({ ...r }))));
|
||
wrap.appendChild(fromModel);
|
||
} else {
|
||
wrap.appendChild(el('p', '지반 모델에 이 교각의 지층이 없습니다 (시추 자료가 있으면 만들어집니다).', 'muted small'));
|
||
}
|
||
}
|
||
|
||
// 단면 (삽도): the footings and piles are the generated model; the layers and water level are the inputs.
|
||
if (pier.model) {
|
||
const fig = el('figure', undefined, 'ground-fig');
|
||
fig.innerHTML = groundSectionSvg(d, pier.model);
|
||
fig.appendChild(el('figcaption', '단면 · 기초와 말뚝은 생성본, 지층과 지하수위는 입력값', 'muted small'));
|
||
wrap.appendChild(fig);
|
||
}
|
||
|
||
// 일괄 적용: copies the 위치 and 지하수위 to the other checked piers (속성 복사 item 'ground-position').
|
||
const others = state.piers.filter((q) => q.checked && q.input.name !== name);
|
||
const batch = el('button', `${name} 외 일괄 대상 ${others.length}기에 적용`, 'btn small');
|
||
batch.type = 'button';
|
||
batch.title = '위치와 지하수위 설정을 체크한 다른 교각에 적용합니다.';
|
||
batch.disabled = others.length === 0;
|
||
batch.addEventListener('click', () => applyGroundPositionTo(pier, others));
|
||
wrap.appendChild(batch);
|
||
|
||
// ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button.
|
||
|
||
wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl'));
|
||
const importInput = el('input');
|
||
importInput.type = 'file';
|
||
importInput.accept = '.json';
|
||
importInput.id = 'ground-import';
|
||
importInput.addEventListener('change', async () => {
|
||
const file = importInput.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
groundModel = parseGroundModel(await file.text());
|
||
setStatus(`${file.name}에서 교각 ${groundModel.piers.length}개의 지반 모델 값을 불러왔습니다. 적용은 교각별로 누르세요.`);
|
||
setupScene();
|
||
renderAll();
|
||
} catch (err) {
|
||
setError(`지반 모델을 읽지 못했습니다: ${(err as Error).message}`);
|
||
} finally {
|
||
importInput.value = '';
|
||
}
|
||
});
|
||
const importLabel = el('label', '지반 모델 파일 불러오기 (json)');
|
||
importLabel.appendChild(importInput);
|
||
wrap.appendChild(importLabel);
|
||
|
||
if (!groundModel) {
|
||
wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small'));
|
||
} else {
|
||
const entry = groundModel.piers.find((q) => q.id === pier.input.name);
|
||
const center = groundElFor(groundModel, pier.input.name);
|
||
if (!entry) {
|
||
wrap.appendChild(el('p', `지반 모델에 ${pier.input.name} 교각이 없습니다.`, 'warn-text'));
|
||
} else if (center === undefined) {
|
||
wrap.appendChild(el('p', '지형 자료 범위 밖이라 지반 모델 값이 없습니다. 입력된 지반고를 그대로 씁니다.', 'warn-text'));
|
||
} else {
|
||
const diff = entry.excelEl === null ? '—' : `${center - entry.excelEl >= 0 ? '+' : ''}${(center - entry.excelEl).toFixed(3)} m`;
|
||
wrap.appendChild(el('p', `지형 계산 ${center.toFixed(3)} m · 엑셀 ${entry.excelEl?.toFixed(3) ?? '—'} m · 차이 ${diff}`));
|
||
const apply = el('button', `지반고 ${center.toFixed(3)} m 적용`, 'btn small');
|
||
apply.type = 'button';
|
||
apply.disabled = pier.draft.groundEl === center;
|
||
apply.addEventListener('click', () => editField(pier.input.name, 'groundEl', String(center)));
|
||
wrap.appendChild(apply);
|
||
}
|
||
wrap.appendChild(el('p', '적용하면 지반고가 변경으로 기록되고, 되돌리기로 엑셀 값으로 돌아갈 수 있습니다. 3D에 반영하려면 다시 생성하세요.', 'muted small'));
|
||
}
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
function renderBearingRef(box: HTMLDivElement, pier: Pier): void {
|
||
const m = pier.model;
|
||
const wrap = el('div', undefined, 'bearing-ref');
|
||
renderBearingBasis(wrap, pier);
|
||
// 받침 종류·용량 (승인 2026-10-26: 글자 입력, 기본 탄성받침). Saved in the project; no rule yet, so it does not change the model.
|
||
wrap.appendChild(el('p', '받침 종류·용량 (글자 입력 · 기본 탄성받침)', 'lbl'));
|
||
const typeInput = el('input');
|
||
typeInput.type = 'text';
|
||
typeInput.id = 'bearing-type';
|
||
typeInput.value = pier.draft.bearingType ?? '탄성받침';
|
||
typeInput.addEventListener('change', () => {
|
||
const v = typeInput.value.trim();
|
||
const p = state.piers.find((q) => q.input.name === pier.input.name);
|
||
if (!p) return;
|
||
p.draft = { ...p.draft, bearingType: v === '' ? undefined : v };
|
||
renderAll();
|
||
});
|
||
wrap.appendChild(typeInput);
|
||
renderBearingLayout(wrap, pier);
|
||
wrap.appendChild(el('p', '공통 규칙 (입력 · 기본값 = 도면값)', 'lbl'));
|
||
const rules: [keyof BearingRules, string][] = [['h2', '모르타르 h2 (mm)'], ['h3', '받침 높이 h3 (mm)'], ['T', '솔플레이트 중심 T (mm)'], ['margin', '솔플 여유 (mm)']];
|
||
const grid = el('div', undefined, 'edit-row');
|
||
for (const [key, label] of rules) {
|
||
const id = `bear-${key}`;
|
||
const lab = el('label', label);
|
||
lab.htmlFor = id;
|
||
const input = el('input');
|
||
input.id = id;
|
||
input.type = 'number';
|
||
input.step = 'any';
|
||
input.value = String(bearingRules[key]);
|
||
const changed = bearingRules[key] !== BEARING_RULES_DEFAULT[key];
|
||
if (changed) input.classList.add('changed-input');
|
||
input.addEventListener('change', () => {
|
||
const v = Number(input.value);
|
||
const min = key === 'margin' ? 0 : 0.0001;
|
||
if (!Number.isFinite(v) || v < min) {
|
||
setError(`${label}은(는) ${key === 'margin' ? '0 이상' : '0보다 큰'} 숫자여야 합니다.`);
|
||
renderAll();
|
||
return;
|
||
}
|
||
bearingRules = { ...bearingRules, [key]: v };
|
||
regenerateAllPiers();
|
||
renderAll();
|
||
});
|
||
grid.append(lab, input, el('span', `기본 ${BEARING_RULES_DEFAULT[key]}`, 'muted small'));
|
||
}
|
||
wrap.appendChild(grid);
|
||
wrap.appendChild(el('p', '규칙 적용 값 (E = h1 + h2 + h3 + h4, L = A + 여유, W = B + 여유, T1·T2 = T ± L·|S|/2)', 'lbl'));
|
||
const ct = el('table', undefined, 'grid-table rebar-table');
|
||
const ch = el('thead');
|
||
const chr = el('tr');
|
||
for (const h of ['No', '열', 'E', 'L', 'W', 'T1 (−측)', 'T2 (+측)', '도면 대비']) chr.appendChild(el('th', h));
|
||
ch.appendChild(chr);
|
||
ct.appendChild(ch);
|
||
const cb = el('tbody');
|
||
for (const r of BEARING_ELEVATION_REF) {
|
||
const v = applyBearingRules(r, bearingRules);
|
||
const d = applyBearingRules(r, BEARING_RULES_DEFAULT);
|
||
const same = v.E === d.E && v.L === d.L && v.W === d.W && v.T1 === d.T1 && v.T2 === d.T2;
|
||
cb.appendChild(el('tr')).append(
|
||
el('td', String(r.no)), el('td', r.row), el('td', String(v.E)), el('td', String(v.L)), el('td', String(v.W)),
|
||
el('td', String(v.T1)), el('td', String(v.T2)), el('td', same ? '같음' : '규칙 변경'),
|
||
);
|
||
}
|
||
ct.appendChild(cb);
|
||
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(ct);
|
||
wrap.appendChild(el('p', '교량받침 ELEVATION TABLE (도면값 · 읽기 전용, mm · EL m)', 'lbl'));
|
||
wrap.appendChild(el('p', '엑셀 z를 EL.X로 씁니다(몰탈 50 mm 포함 받침블럭 상면, Q16 결정 2026-10-23). 도면 EL.X와의 차이는 "도면 ≠ 엑셀"로만 표시합니다.', 'muted small'));
|
||
const front = m?.blocks.filter((b) => b.row === '전열') ?? [];
|
||
const backBlocks = m?.blocks.filter((b) => b.row === '후열') ?? [];
|
||
const et = el('table', undefined, 'grid-table rebar-table');
|
||
const eh = el('thead');
|
||
const ehr = el('tr');
|
||
for (const h of ['No', '열', '엑셀 z (= EL.X, 모델)', '도면 EL.X', '도면 − 엑셀 (mm)', 'E', 'A', 'B', 'C1', 'S1']) ehr.appendChild(el('th', h));
|
||
eh.appendChild(ehr);
|
||
et.appendChild(eh);
|
||
const eb = el('tbody');
|
||
for (const r of BEARING_ELEVATION_REF) {
|
||
// Excel z is EL.X (Q16 decision 2026-10-23, option 1); the drawing's EL.X is shown for comparison only.
|
||
const block = r.row === '전열' ? front[r.no - 1] : backBlocks[r.no - 6];
|
||
const excelZ = block?.padTop;
|
||
const diffMm = excelZ === undefined ? NaN : Math.round((r.elx - excelZ) * 1000);
|
||
eb.appendChild(el('tr')).append(
|
||
el('td', String(r.no)), el('td', r.row), el('td', excelZ === undefined ? '—' : excelZ.toFixed(3)), el('td', r.elx.toFixed(3)),
|
||
el('td', Number.isNaN(diffMm) ? '—' : String(diffMm)), el('td', String(r.E)),
|
||
el('td', String(r.A)), el('td', String(r.B)), el('td', String(r.C1)), el('td', String(r.S1)),
|
||
);
|
||
}
|
||
et.appendChild(eb);
|
||
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(et);
|
||
|
||
wrap.appendChild(el('p', '솔플레이트 TABLE (도면값, mm)', 'lbl'));
|
||
const st = el('table', undefined, 'grid-table rebar-table');
|
||
const sh = el('thead');
|
||
const shr = el('tr');
|
||
for (const h of ['No', 'S (%)', 'L', 'W', 'T (중심)', 'T1 (−측)', 'T2 (+측)', '식 검산']) shr.appendChild(el('th', h));
|
||
sh.appendChild(shr);
|
||
st.appendChild(sh);
|
||
const sb = el('tbody');
|
||
for (const r of SOLPLATE_REF) {
|
||
const calc = solplateTs(SOLPLATE_T_RULE, r.L, r.s);
|
||
const ok = calc.T1 === r.T1 && calc.T2 === r.T2;
|
||
sb.appendChild(el('tr')).append(
|
||
el('td', String(r.no)), el('td', r.s.toFixed(3)), el('td', String(r.L)), el('td', String(r.W)),
|
||
el('td', String(r.T)), el('td', String(r.T1)), el('td', String(r.T2)), el('td', ok ? '일치' : `식 ${calc.T1} / ${calc.T2}`),
|
||
);
|
||
}
|
||
st.appendChild(sb);
|
||
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(st);
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
/** ② 코핑 상세 (2단계, 읽기 전용): d_IP2 and the 계단 구간 표 from the model (승인 2026-10-23). */
|
||
function renderCopingInfo(box: HTMLDivElement, pier: Pier): void {
|
||
const m = pier.model;
|
||
if (!m) return;
|
||
const wrap = el('div', undefined, 'coping-info');
|
||
wrap.appendChild(el('p', `d_IP2 (자동) ${m.dIp2.toFixed(3)} m`, 'num'));
|
||
wrap.appendChild(el('p', '계단 구간 (BP1 기준 m · EL m · mm)', 'lbl'));
|
||
const table = el('table', undefined, 'grid-table rebar-table');
|
||
const head = el('thead');
|
||
const hr = el('tr');
|
||
for (const h of ['구간', '시작', '끝', '전열 상면 EL', '후열 상면 EL', '횡방향 단차 (mm)', '받침블럭 높이 전열 / 후열 (mm)']) hr.appendChild(el('th', h));
|
||
head.appendChild(hr);
|
||
table.appendChild(head);
|
||
const body = el('tbody');
|
||
const front = m.blocks.filter((b) => b.row === '전열');
|
||
const back = m.blocks.filter((b) => b.row === '후열');
|
||
m.segments.forEach((seg, k) => {
|
||
const f = front[k];
|
||
const b = back[k];
|
||
const backTop = b ? b.bottom : seg.top;
|
||
const step = Math.round((seg.top - backTop) * 1000);
|
||
// Block height includes the 50 mm pad (spec Q16): from the coping top to the pad top.
|
||
const heights = f && b ? `${Math.round((f.padTop - f.bottom) * 1000)} / ${Math.round((b.padTop - b.bottom) * 1000)}` : '—';
|
||
body.appendChild(el('tr')).append(
|
||
el('td', String(k + 1)), el('td', seg.s0.toFixed(3)), el('td', seg.s1.toFixed(3)),
|
||
el('td', seg.top.toFixed(3)), el('td', backTop.toFixed(3)), el('td', String(step)), el('td', heights),
|
||
);
|
||
});
|
||
table.appendChild(body);
|
||
wrap.appendChild(table);
|
||
wrap.appendChild(el('p', '기본 외 형상 규칙(교좌면 모따기·라운드, 2·3주형 헌치)은 spec 2단계 확정 후 추가합니다.', 'muted small'));
|
||
box.appendChild(wrap);
|
||
}
|
||
|
||
function renderPlan(): void {
|
||
const pier = activePier();
|
||
const members = $<HTMLUListElement>('members');
|
||
members.replaceChildren();
|
||
for (const m of MEMBERS) {
|
||
const li = el('li');
|
||
const btn = el('button', m.label, 'member');
|
||
btn.type = 'button';
|
||
btn.setAttribute('aria-pressed', String(m.id === state.member));
|
||
const changed = pier ? overrideKeys(pier).filter((k) => m.keys.includes(k)).length : 0;
|
||
btn.append(el('span', changed ? `변경 ${changed}` : '변경 0', 'badge'));
|
||
btn.addEventListener('click', () => {
|
||
state.member = m.id;
|
||
renderAll();
|
||
});
|
||
li.appendChild(btn);
|
||
members.appendChild(li);
|
||
}
|
||
|
||
const member = MEMBERS.find((m) => m.id === state.member)!;
|
||
$('member-title').textContent = member.label;
|
||
// Default and current selection are shown separately (spec §5.1). Only defaults are selectable today.
|
||
// 선택 shows the pier's own 위치 for the ground; the other members still choose only their defaults.
|
||
const selected = member.id === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member.id === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : defaultSummary(member.id);
|
||
$('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${selected}`;
|
||
renderShapeOptions(member.id, pier);
|
||
$('member-note').textContent = member.note;
|
||
if (member.id === 'bearing' && pier?.model) {
|
||
// Row skew of the bearing blocks (계산값): the block sides follow the bearing row (승인 2026-10-23).
|
||
const front = pier.model.blocks.find((b) => b.row === '전열');
|
||
const back = pier.model.blocks.find((b) => b.row === '후열');
|
||
$('member-note').textContent += ` 받침 열 기울기(교축 직각 기준, 계산값): 전열 ${front?.rowSkewDeg.toFixed(2) ?? '—'}° · 후열 ${back?.rowSkewDeg.toFixed(2) ?? '—'}°.`;
|
||
}
|
||
const regen = $<HTMLButtonElement>('member-regen');
|
||
const fields = $<HTMLDivElement>('member-fields');
|
||
if (!pier) {
|
||
fields.replaceChildren();
|
||
regen.textContent = '다시 생성 · 변경 0';
|
||
regen.disabled = true;
|
||
} else {
|
||
fieldRows(fields, pier, member.keys);
|
||
if (member.id === 'footing') {
|
||
renderPileSpec(fields, pier);
|
||
renderFootingDrawings(fields, pier);
|
||
}
|
||
if (member.id === 'coping') renderCopingInfo(fields, pier);
|
||
if (member.id === 'bearing') renderBearingRef(fields, pier);
|
||
if (member.id === 'ground') renderGroundModel(fields, pier);
|
||
if (FCK_MEMBERS.includes(member.id as FckMember)) renderMemberFck(fields, member.id as FckMember);
|
||
regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`;
|
||
regen.disabled = pendingKeys(pier).length === 0;
|
||
}
|
||
|
||
const changes = $<HTMLUListElement>('changes');
|
||
changes.replaceChildren();
|
||
const pending = pier ? pendingKeys(pier) : [];
|
||
for (const key of pier ? overrideKeys(pier) : []) {
|
||
const li = el('li', `${FIELD_LABELS[key]}: 기본 ${valueText(key, pier!.defaults[key])} → 현재 ${valueText(key, pier!.draft[key])}${pending.includes(key) ? ' (미반영)' : ''}`);
|
||
const revert = el('button', '되돌리기', 'btn small');
|
||
revert.type = 'button';
|
||
revert.addEventListener('click', () => revertField(pier!.input.name, key));
|
||
li.appendChild(revert);
|
||
changes.appendChild(li);
|
||
}
|
||
if (changes.childElementCount === 0) changes.appendChild(el('li', '기본값과 같습니다.'));
|
||
|
||
const issues = $<HTMLUListElement>('plan-issues');
|
||
issues.replaceChildren();
|
||
for (const i of pier?.issues ?? []) issues.appendChild(el('li', `${i.code} ${i.message}`));
|
||
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
|
||
}
|
||
|
||
// --- project file (plan B3·B4·B5) --------------------------------------------------------
|
||
/** Everything that a save writes except the history, so that a save itself does not look like a change. */
|
||
function snapshot(): string {
|
||
return JSON.stringify({
|
||
project: state.project,
|
||
source: state.source,
|
||
piers: state.piers.map((p) => ({ input: p.input, defaults: p.defaults, draft: p.draft })),
|
||
material: state.material,
|
||
fckByMember: state.fckByMember,
|
||
bearingBasis,
|
||
bearingDrawing,
|
||
groundModel,
|
||
});
|
||
}
|
||
|
||
function isDirty(): boolean {
|
||
return state.piers.length > 0 && snapshot() !== state.savedSnapshot;
|
||
}
|
||
|
||
function currentProjectText(): string {
|
||
return serializeProject({
|
||
schemaVersion: SCHEMA_VERSION,
|
||
project: { ...state.project },
|
||
source: state.source,
|
||
piers: state.piers.map((p) => ({ id: p.input.name, input: p.input, defaults: p.defaults, draft: p.draft })),
|
||
history: state.history,
|
||
common: state.material,
|
||
fckByMember: state.fckByMember,
|
||
bearingBasis,
|
||
bearingDrawing,
|
||
groundModel: groundModel ?? undefined,
|
||
});
|
||
}
|
||
|
||
function renderSaveStatus(): void {
|
||
const saveBtn = $<HTMLButtonElement>('save-project');
|
||
saveBtn.disabled = state.piers.length === 0;
|
||
const status = $('save-status');
|
||
if (state.piers.length === 0) {
|
||
status.textContent = '저장 전';
|
||
} else if (state.savedSnapshot === null || isDirty()) {
|
||
status.textContent = state.savedSnapshot === null ? '저장 전 · 변경 있음' : '변경 있음 · 미저장';
|
||
} else {
|
||
status.textContent = '저장됨';
|
||
}
|
||
}
|
||
|
||
/** 저장 button and Ctrl+S: the first save opens the project dialog (시안 ⑤); later saves write the same file. */
|
||
function saveProject(): void {
|
||
if (state.piers.length === 0) return;
|
||
if (!state.savedOnce) {
|
||
openSaveDialog();
|
||
return;
|
||
}
|
||
writeProjectFile();
|
||
}
|
||
|
||
function projectFileName(): string {
|
||
return `${state.project.bridge || state.project.name || '교각설계'}_교각설계.pzr.json`;
|
||
}
|
||
|
||
function openSaveDialog(): void {
|
||
$<HTMLInputElement>('sv-name').value = state.project.name;
|
||
$<HTMLInputElement>('sv-bridge').value = state.project.bridge;
|
||
$<HTMLInputElement>('sv-client').value = state.project.client;
|
||
$<HTMLInputElement>('sv-author').value = state.project.author;
|
||
$<HTMLTextAreaElement>('sv-memo').value = state.project.memo;
|
||
$('sv-file').textContent = projectFileName();
|
||
$<HTMLDialogElement>('save-dialog').showModal();
|
||
$<HTMLInputElement>('sv-name').focus();
|
||
}
|
||
|
||
function confirmSaveDialog(): void {
|
||
const name = $<HTMLInputElement>('sv-name').value.trim();
|
||
if (!name) {
|
||
$<HTMLInputElement>('sv-name').focus();
|
||
setError('프로젝트명을 입력하세요.');
|
||
return;
|
||
}
|
||
state.project = {
|
||
name,
|
||
bridge: $<HTMLInputElement>('sv-bridge').value.trim(),
|
||
client: $<HTMLInputElement>('sv-client').value.trim(),
|
||
author: $<HTMLInputElement>('sv-author').value.trim(),
|
||
crs: CRS,
|
||
memo: $<HTMLTextAreaElement>('sv-memo').value.trim(),
|
||
};
|
||
$<HTMLDialogElement>('save-dialog').close();
|
||
writeProjectFile();
|
||
}
|
||
|
||
function writeProjectFile(): void {
|
||
state.savedOnce = true;
|
||
state.history = [...state.history, { version: String(state.history.length + 1), note: '저장', at: new Date().toISOString() }];
|
||
const text = currentProjectText();
|
||
const name = projectFileName();
|
||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = name;
|
||
link.click();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
state.savedSnapshot = snapshot();
|
||
clearBackup().catch(() => undefined);
|
||
renderHeader();
|
||
setStatus(`${name} 파일을 저장했습니다.`);
|
||
}
|
||
|
||
/** Opens a project from its text. A backup stays unsaved until the user saves it. */
|
||
function openProjectText(text: string, saved: boolean): void {
|
||
const parsed = parseProject(text);
|
||
state.project = { ...parsed.project };
|
||
state.source = parsed.source;
|
||
state.history = parsed.history;
|
||
state.material = materialFrom(parsed.common);
|
||
state.fckByMember = { ...parsed.fckByMember };
|
||
bearingBasis = parsed.bearingBasis ?? 'excel';
|
||
bearingDrawing = Object.fromEntries(Object.entries(parsed.bearingDrawing ?? {}).map(([k, v]) => [k, [...v]]));
|
||
groundModel = parsed.groundModel ?? null;
|
||
state.piers = parsed.piers.map((p) => ({
|
||
...buildEntry(p.input, false),
|
||
defaults: p.defaults ?? p.input,
|
||
draft: p.draft ?? p.input,
|
||
}));
|
||
state.active = state.piers[0]?.input.name ?? null;
|
||
state.filter = 'all';
|
||
state.pending = null;
|
||
state.savedSnapshot = saved ? snapshot() : null;
|
||
setupScene();
|
||
renderAll();
|
||
}
|
||
|
||
async function openProjectFile(file: File): Promise<void> {
|
||
try {
|
||
openProjectText(await file.text(), true);
|
||
state.savedOnce = true;
|
||
clearBackup().catch(() => undefined);
|
||
setStatus(`${file.name}을 열었습니다. ${state.piers.length}기를 원본 엑셀 없이 다시 생성했습니다.`);
|
||
} catch (err) {
|
||
setError(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
function scheduleBackup(): void {
|
||
window.clearTimeout(backupTimer);
|
||
backupTimer = window.setTimeout(() => {
|
||
if (!isDirty()) return;
|
||
writeBackup(currentProjectText()).catch(() => setError('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.'));
|
||
}, 800);
|
||
}
|
||
|
||
async function checkBackup(): Promise<void> {
|
||
try {
|
||
const backup = await readBackup();
|
||
if (!backup) return;
|
||
pendingBackup = backup;
|
||
$('backup-text').textContent = `저장되지 않은 작업이 있습니다 (${new Date(backup.at).toLocaleString('ko-KR')}).`;
|
||
$<HTMLDivElement>('backup-banner').hidden = false;
|
||
} catch {
|
||
// Storage unavailable (private window, blocked): no recovery is offered, and the app works without it.
|
||
}
|
||
}
|
||
|
||
// --- import dialog (시안 ④) ------------------------------------------------------------
|
||
function selectable(p: Pending, e: Pier): boolean {
|
||
return !!e.model && sheetOk(p.sheets, e.input.sheet);
|
||
}
|
||
|
||
function renderDialog(): void {
|
||
const p = state.pending;
|
||
const sheetsBox = $<HTMLUListElement>('sheets');
|
||
sheetsBox.replaceChildren();
|
||
$('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : '';
|
||
if (!p) {
|
||
sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.'));
|
||
renderPreview(null);
|
||
updateApply();
|
||
return;
|
||
}
|
||
|
||
for (const sheet of p.sheets) {
|
||
const li = el('li');
|
||
const head = el('div', undefined);
|
||
const label =
|
||
sheet.piers === 0 ? (sheet.ok ? '보조 표 시트 (IP·받침)' : '오류') : sheet.ok ? '선택 가능' : '선택 불가 (오류)';
|
||
head.append(
|
||
el('span', `${sheet.name} · ${label}`, sheet.ok ? 'ok' : 'bad'),
|
||
document.createTextNode(` · 교각 ${sheet.tables.pier}표 · IP ${sheet.tables.ip}표 · 받침 ${sheet.tables.bearing}표`),
|
||
);
|
||
li.appendChild(head);
|
||
for (const issue of sheet.issues) {
|
||
const line = el('div', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, 'small');
|
||
line.style.color = issue.level === 'E' ? 'var(--error)' : 'var(--muted)';
|
||
li.appendChild(line);
|
||
}
|
||
for (const entry of p.entries.filter((e) => e.input.sheet === sheet.name)) {
|
||
const row = el('div', undefined, 'sheet-pier');
|
||
const check = el('input');
|
||
check.type = 'checkbox';
|
||
check.checked = entry.checked;
|
||
check.disabled = !selectable(p, entry);
|
||
check.setAttribute('aria-label', `${entry.input.name} 불러오기`);
|
||
check.addEventListener('change', () => {
|
||
entry.checked = check.checked;
|
||
updateApply();
|
||
});
|
||
const name = el('button', `${entry.input.name} · ${entry.input.type}`, 'pier-name');
|
||
name.type = 'button';
|
||
name.setAttribute('aria-pressed', String(p.preview === entry.input.name));
|
||
name.addEventListener('click', () => {
|
||
p.preview = entry.input.name;
|
||
renderDialog();
|
||
});
|
||
row.append(check, name);
|
||
li.appendChild(row);
|
||
}
|
||
sheetsBox.appendChild(li);
|
||
}
|
||
const preview = p.entries.find((e) => e.input.name === p.preview) ?? p.entries.find((e) => selectable(p, e)) ?? null;
|
||
renderPreview(preview ? preview.input : null);
|
||
updateApply();
|
||
}
|
||
|
||
function renderPreview(input: PierInput | null): void {
|
||
const title = $('preview-title');
|
||
const pierTable = $<HTMLTableElement>('pv-pier').tBodies[0];
|
||
const bearingBody = $<HTMLTableElement>('pv-bearings').tBodies[0];
|
||
const ipBody = $<HTMLTableElement>('pv-ips').tBodies[0];
|
||
pierTable.replaceChildren();
|
||
bearingBody.replaceChildren();
|
||
ipBody.replaceChildren();
|
||
if (!input) {
|
||
title.textContent = '미리볼 교각이 없습니다.';
|
||
return;
|
||
}
|
||
title.textContent = `${input.name} · 시트 ${input.sheet}`;
|
||
const pairs: Pair[] = [
|
||
['교각명', input.name],
|
||
['교각형식', input.type],
|
||
['두부보 EL (코핑 상면)', f3(input.copingTopEl)],
|
||
['기초 하단 EL', f3(input.footingBottomEl)],
|
||
['지반고 EL', f3(input.groundEl)],
|
||
['기초 형식', input.foundationType],
|
||
['말뚝길이 (mm)', String(input.pileLengthMm)],
|
||
['두부보 폭 × 길이 (mm)', `${input.copingWidthMm} × ${input.copingLengthMm}`],
|
||
['기둥갯수', String(input.columnCount)],
|
||
];
|
||
for (const [k, v] of pairs) {
|
||
const tr = el('tr');
|
||
tr.append(el('th', k), el('td', v));
|
||
pierTable.appendChild(tr);
|
||
}
|
||
for (const b of input.bearings) {
|
||
const tr = el('tr');
|
||
const azimuth = Number.isNaN(b.azimuthDeg) ? '오류' : `${b.azimuthDeg.toFixed(4)}°`;
|
||
for (const v of [String(b.no), b.x.toFixed(3), b.y.toFixed(3), f3(b.z), azimuth, b.row]) tr.appendChild(el('td', v));
|
||
bearingBody.appendChild(tr);
|
||
}
|
||
for (const ip of input.ips) {
|
||
const tr = el('tr');
|
||
for (const v of [ip.name, ip.x.toFixed(3), ip.y.toFixed(3), `${ip.angleDeg}°`]) tr.appendChild(el('td', v));
|
||
ipBody.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
function updateApply(): void {
|
||
const p = state.pending;
|
||
const count = p ? p.entries.filter((e) => e.checked && selectable(p, e)).length : 0;
|
||
const total = p ? p.entries.filter((e) => selectable(p, e)).length : 0;
|
||
$('dlg-selected').textContent = `선택 ${count} / ${total}기`;
|
||
const named = $<HTMLInputElement>('project-name').value.trim().length > 0;
|
||
const apply = $<HTMLButtonElement>('dlg-apply');
|
||
apply.textContent = `선택한 교각 ${count}기 형상 생성`;
|
||
apply.disabled = count === 0 || !named;
|
||
}
|
||
|
||
function loadBytes(bytes: Uint8Array, fileName: string): void {
|
||
const { piers, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' }));
|
||
const entries = piers.map((input) => buildEntry(input, false));
|
||
// A sheet is selectable only if every pier in it builds. Show the reason next to the sheet.
|
||
for (const sheet of sheets) {
|
||
const own = entries.filter((e) => e.input.sheet === sheet.name);
|
||
for (const e of own) sheet.issues.push(...e.issues.filter((i) => i.level === 'E'));
|
||
sheet.ok = sheet.ok && own.every((e) => e.model !== null);
|
||
}
|
||
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 = { ...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;
|
||
state.source = { fileName: p.fileName, sheets: p.sheets.map((sheet) => sheet.name) };
|
||
state.history = [];
|
||
state.savedSnapshot = null;
|
||
state.savedOnce = false;
|
||
$<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 = regenerateEntry(p);
|
||
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);
|
||
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
|
||
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
|
||
$<HTMLButtonElement>('rebar-bulk-copy').addEventListener('click', openCopyDialog);
|
||
$<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shape'));
|
||
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
|
||
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
|
||
$<HTMLButtonElement>('nav-quantity').addEventListener('click', () => setStage('quantity'));
|
||
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
|
||
$<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
|
||
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
|
||
$<HTMLInputElement>(`bend-${k}`).addEventListener('change', (ev) => setBend(k, (ev.target as HTMLInputElement).value));
|
||
}
|
||
for (const k of ['D', 'H', 'S'] as const) {
|
||
$<HTMLSelectElement>(`mat-grade-${k.toLowerCase()}`).addEventListener('change', (ev) => setMaterial(k, (ev.target as HTMLSelectElement).value));
|
||
}
|
||
$<HTMLButtonElement>('member-regen').addEventListener('click', () => {
|
||
const pier = activePier();
|
||
if (pier) regenerateOne(pier.input.name);
|
||
});
|
||
$<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
|
||
renderCopyTargets();
|
||
renderCopyPreview();
|
||
});
|
||
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
|
||
i.addEventListener('change', renderCopyPreview);
|
||
}
|
||
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
|
||
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyDialog);
|
||
$<HTMLButtonElement>('cp-cancel').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
|
||
$<HTMLButtonElement>('cp-close').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
|
||
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
|
||
$<HTMLButtonElement>('sv-cancel').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
|
||
$<HTMLButtonElement>('sv-close').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
|
||
$<HTMLButtonElement>('edit-regen').addEventListener('click', () => {
|
||
const pier = activePier();
|
||
if (pier) regenerateOne(pier.input.name);
|
||
});
|
||
$<HTMLInputElement>('project-name').addEventListener('input', updateApply);
|
||
|
||
$<HTMLButtonElement>('pick-file').addEventListener('click', () => $<HTMLInputElement>('file').click());
|
||
$<HTMLButtonElement>('download-template').addEventListener('click', () => {
|
||
XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx');
|
||
});
|
||
$<HTMLInputElement>('file').addEventListener('change', async (ev) => {
|
||
const file = (ev.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
|
||
} catch (err) {
|
||
setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
|
||
}
|
||
});
|
||
/** The project's standing input and names (RAG/운곡교P1입력.xlsx → 프로젝트 운곡교P1, 교량 운곡교). */
|
||
const SAMPLE_PROJECT = { name: '운곡교P1', bridge: '운곡교' } as const;
|
||
|
||
function fillSampleNames(): void {
|
||
$<HTMLInputElement>('project-name').value = SAMPLE_PROJECT.name;
|
||
$<HTMLInputElement>('bridge-name').value = SAMPLE_PROJECT.bridge;
|
||
// Setting .value does not fire 'input', so the apply button must be refreshed here.
|
||
updateApply();
|
||
}
|
||
|
||
$<HTMLButtonElement>('pick-sample').addEventListener('click', async () => {
|
||
try {
|
||
loadBytes(await fetchSample(), '샘플 P1 (운곡교P1입력.xlsx)');
|
||
fillSampleNames();
|
||
} catch (err) {
|
||
setError(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
|
||
}
|
||
});
|
||
|
||
const drop = $<HTMLDivElement>('drop');
|
||
drop.addEventListener('dragover', (ev) => {
|
||
ev.preventDefault();
|
||
drop.classList.add('over');
|
||
});
|
||
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
|
||
drop.addEventListener('drop', async (ev) => {
|
||
ev.preventDefault();
|
||
drop.classList.remove('over');
|
||
const file = ev.dataTransfer?.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
|
||
} catch (err) {
|
||
setError(`엑셀을 읽지 못했습니다: ${(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', () => {
|
||
state.piers = setVisibleChecked(state.piers, state.filter, !allVisibleChecked(state.piers, state.filter));
|
||
renderAll();
|
||
});
|
||
|
||
$<HTMLButtonElement>('save-project').addEventListener('click', saveProject);
|
||
$<HTMLButtonElement>('open-project').addEventListener('click', () => $<HTMLInputElement>('project-file').click());
|
||
$<HTMLInputElement>('project-file').addEventListener('change', (ev) => {
|
||
const file = (ev.target as HTMLInputElement).files?.[0];
|
||
if (file) void openProjectFile(file);
|
||
(ev.target as HTMLInputElement).value = '';
|
||
});
|
||
window.addEventListener('keydown', (ev) => {
|
||
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 's') {
|
||
ev.preventDefault();
|
||
saveProject();
|
||
}
|
||
});
|
||
window.addEventListener('beforeunload', (ev) => {
|
||
if (isDirty()) ev.preventDefault();
|
||
});
|
||
$<HTMLButtonElement>('restore-backup').addEventListener('click', () => {
|
||
if (!pendingBackup) return;
|
||
try {
|
||
openProjectText(pendingBackup.text, false);
|
||
setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.');
|
||
} catch (err) {
|
||
setError(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
|
||
}
|
||
pendingBackup = null;
|
||
$<HTMLDivElement>('backup-banner').hidden = true;
|
||
});
|
||
$<HTMLButtonElement>('discard-backup').addEventListener('click', () => {
|
||
pendingBackup = null;
|
||
$<HTMLDivElement>('backup-banner').hidden = true;
|
||
clearBackup().catch(() => undefined);
|
||
});
|
||
|
||
// --- start ------------------------------------------------------------------------------
|
||
captureViewerHome();
|
||
wireRebarPicking();
|
||
buildPartToggles();
|
||
buildViewButtons();
|
||
renderDefaults();
|
||
renderDialog();
|
||
renderAll();
|
||
void checkBackup();
|
||
|
||
// Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog.
|
||
if (new URLSearchParams(window.location.search).get('sample') === '1') {
|
||
fetchSample()
|
||
.then((bytes) => {
|
||
loadBytes(bytes, '샘플 P1');
|
||
fillSampleNames();
|
||
applyImport();
|
||
})
|
||
.catch((err: Error) => setError(`샘플을 불러오지 못했습니다: ${err.message}`));
|
||
}
|
||
|
||
// --- 3D band: collapsible view menu, origin toggle, and remembered panel states (승인 2026-10-23) ---
|
||
/** Open or close the view menu or the origin note, and keep the buttons' aria-expanded in step. */
|
||
function setMenuOpen(which: 'views' | 'origin', open: boolean): void {
|
||
const panel = $<HTMLElement>(which);
|
||
panel.hidden = !open;
|
||
$(`${which}-toggle`).setAttribute('aria-expanded', String(open));
|
||
}
|
||
|
||
/** Counts the bar parts shown on the 3D view in the 부재 표시 summary. */
|
||
function updatePartsCount(): void {
|
||
const boxes = document.querySelectorAll<HTMLInputElement>('#parts input[type="checkbox"]');
|
||
const on = Array.from(boxes).filter((b) => b.checked).length;
|
||
$('parts-count').textContent = `${on}/${boxes.length} ▾`;
|
||
}
|
||
|
||
/** Panel open states remembered per viewer; the storage can be missing or blocked, so every access is guarded. */
|
||
const PANEL_KEY = 'pierzainer.panel.';
|
||
function restorePanel(id: string): void {
|
||
const panel = $<HTMLDetailsElement>(id);
|
||
try {
|
||
panel.open = localStorage.getItem(PANEL_KEY + id) === '1';
|
||
} catch {
|
||
panel.open = false;
|
||
}
|
||
panel.addEventListener('toggle', () => {
|
||
try {
|
||
localStorage.setItem(PANEL_KEY + id, panel.open ? '1' : '0');
|
||
} catch {
|
||
/* storage unavailable: the panel still works */
|
||
}
|
||
});
|
||
}
|
||
restorePanel('parts-info');
|
||
restorePanel('color-info');
|
||
|
||
$<HTMLButtonElement>('views-toggle').addEventListener('click', () => setMenuOpen('views', $<HTMLDivElement>('views').hidden === true));
|
||
$<HTMLButtonElement>('origin-toggle').addEventListener('click', () => setMenuOpen('origin', $<HTMLSpanElement>('origin').hidden === true));
|
||
document.addEventListener('click', (ev) => {
|
||
const target = ev.target as Node;
|
||
if (!$<HTMLDivElement>('views-menu').contains(target)) setMenuOpen('views', false);
|
||
if (!$<HTMLButtonElement>('origin-toggle').contains(target) && !$<HTMLSpanElement>('origin').contains(target)) setMenuOpen('origin', false);
|
||
});
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Escape') {
|
||
setMenuOpen('views', false);
|
||
setMenuOpen('origin', false);
|
||
}
|
||
});
|