feat(view): ④ 재료표 as its own result stage, with pier selection, Excel per pier and print
The 철근재료표 moves from ③ to a new stage ④ in the header. It shows the checked piers (or the current one), one table per pier with 강종 → 직경 → 번호 and subtotals, and a total across piers. The Excel export has one sheet per pier and a 합계 sheet. Print uses A4 landscape without the header and buttons. ③ keeps a summary line and a link to ④. Spec §5, §6.9 and §7 record the stage (approved 2026-10-23). Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6bf087334
commit
99c71b7277
3 files changed
+110
-30
No files matched your search
+81
-30
@@ -14,7 +14,7 @@ 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 } from './core/rebar/quantity';
|
||||
import { buildQuantity, quantityRows, type Quantity } from './core/rebar/quantity';
|
||||
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';
|
||||
@@ -30,6 +30,9 @@ interface Pier extends PierValues {
|
||||
}
|
||||
|
||||
/** 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'] },
|
||||
{ label: '코핑', parts: ['coping'] },
|
||||
@@ -74,7 +77,7 @@ const state = {
|
||||
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 'shape' | 'plan' | 'rebar',
|
||||
stage: 'shape' as Stage,
|
||||
/** ③ 공통 설정 (fck · fy). */
|
||||
material: { ...DEFAULT_MATERIAL } as Material,
|
||||
/** ② 부재별 fck. A member without an entry uses the 공통 fck (③). */
|
||||
@@ -270,7 +273,7 @@ function renderPierList(): void {
|
||||
}
|
||||
|
||||
/** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */
|
||||
function renderPierRows(list: HTMLUListElement, mode: 'shape' | 'plan' | 'rebar'): void {
|
||||
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) {
|
||||
@@ -295,7 +298,7 @@ function renderPierRows(list: HTMLUListElement, mode: 'shape' | 'plan' | 'rebar'
|
||||
});
|
||||
const badge = mode === 'plan'
|
||||
? el('span', `변경 ${overrideKeys(pier).length}`, 'badge')
|
||||
: mode === 'rebar'
|
||||
: 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')
|
||||
@@ -425,6 +428,7 @@ function renderAll(): void {
|
||||
renderPlan();
|
||||
renderRebar();
|
||||
renderRebarColor();
|
||||
if (state.stage === 'quantity') renderQuantityScreen();
|
||||
scheduleBackup();
|
||||
}
|
||||
|
||||
@@ -710,7 +714,7 @@ function captureViewerHome(): void {
|
||||
viewerHome.next = block.nextElementSibling;
|
||||
}
|
||||
|
||||
function placeViewer(stage: 'shape' | 'plan' | 'rebar'): void {
|
||||
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);
|
||||
@@ -750,17 +754,19 @@ function wireRebarPicking(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function setStage(stage: 'shape' | 'plan' | 'rebar'): void {
|
||||
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, 'shape' | 'plan' | 'rebar'][] = [['nav-shape', 'shape'], ['nav-plan', 'plan'], ['nav-rebar', 'rebar']];
|
||||
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);
|
||||
@@ -950,18 +956,31 @@ function renderRebar(): void {
|
||||
box.appendChild(st);
|
||||
}
|
||||
// Table and 3D model point at each other: the focused bar is highlighted in both.
|
||||
renderQuantity(box, rows, pier.input.name);
|
||||
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' });
|
||||
}
|
||||
|
||||
/** 철근재료표 (whole pier): bars by 강종 → 직경 → 번호 with subtotals; 할증 and the Excel export (승인 2026-10-23). */
|
||||
function renderQuantity(box: HTMLElement, rows: RebarRow[], pierName: string): void {
|
||||
/** ③ keeps only a link to the 철근재료표 screen ④ (승인 2026-10-23). */
|
||||
function renderQuantityLink(box: HTMLElement, rows: RebarRow[]): void {
|
||||
const q = buildQuantity(rows, state.material);
|
||||
box.appendChild(el('p', '철근재료표 · 교각 전체', 'lbl'));
|
||||
const info = el('p', `할증 ${q.wastePercent}% (기본 0%, 가정). 길이가 없는 철근 ${q.missing.length}개는 제외했습니다${q.missing.length ? `: ${q.missing.join(', ')}` : ''}. 커플러는 개수만 적습니다.`, 'muted small');
|
||||
box.appendChild(info);
|
||||
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] : [];
|
||||
}
|
||||
|
||||
function quantityTable(q: Quantity): HTMLTableElement {
|
||||
const table = el('table', undefined, 'grid-table rebar-table');
|
||||
const head = el('thead');
|
||||
const hr = el('tr');
|
||||
@@ -972,7 +991,7 @@ function renderQuantity(box: HTMLElement, rows: RebarRow[], pierName: string): v
|
||||
for (const g of q.groups) {
|
||||
for (const d of g.diameters) {
|
||||
for (const l of d.lines) {
|
||||
body.appendChild(el('tr', undefined)).append(
|
||||
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)),
|
||||
el('td', String(l.length)), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)),
|
||||
);
|
||||
@@ -982,24 +1001,55 @@ function renderQuantity(box: HTMLElement, rows: RebarRow[], pierName: string): v
|
||||
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);
|
||||
box.appendChild(table);
|
||||
const totals = el('ul', undefined, 'pnote');
|
||||
totals.append(
|
||||
el('li', `합계 ${q.kg.toFixed(1)} kg`),
|
||||
el('li', `할증 ${q.wastePercent}% 포함 ${q.kgWithWaste.toFixed(1)} kg · ${q.ton.toFixed(3)} ton`),
|
||||
...q.couplers.map((c) => el('li', `${c.name}: ${c.count}개`)),
|
||||
);
|
||||
box.appendChild(totals);
|
||||
const exportBtn = el('button', '엑셀로 내보내기', 'btn small');
|
||||
exportBtn.type = 'button';
|
||||
exportBtn.addEventListener('click', () => {
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(quantityRows(q)), '철근재료표');
|
||||
XLSX.writeFile(wb, `${pierName}_철근재료표.xlsx`);
|
||||
});
|
||||
box.appendChild(exportBtn);
|
||||
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%, 가정). 체크한 교각 기준이고, 체크가 없으면 현재 교각입니다.`
|
||||
: '계산된 교각이 없습니다. 교각을 생성하세요.';
|
||||
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');
|
||||
@@ -1535,6 +1585,7 @@ $<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) {
|
||||
|
||||
@@ -709,3 +709,16 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.viewer-head .rebar-color { margin: 0 auto; }
|
||||
.rebar-select { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 6px 0; }
|
||||
.rebar-select .btn { min-height: 44px; }
|
||||
|
||||
/* ④ 재료표 (승인 2026-10-23) */
|
||||
.grid.quantity { grid-template-columns: 260px minmax(0, 1fr); }
|
||||
.quantity .subtotal td { font-weight: 700; background: #f3f6fa; }
|
||||
.quantity h3 { margin: 14px 0 4px; font-size: 15px; }
|
||||
@media print {
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
body * { visibility: hidden; }
|
||||
#view-quantity, #view-quantity * { visibility: visible; }
|
||||
#view-quantity { position: absolute; left: 0; top: 0; width: 100%; padding: 0; }
|
||||
#view-quantity .piers, #quantity-export, #quantity-print { display: none !important; }
|
||||
.quantity .rebar-table { font-size: 10px; }
|
||||
}
|
||||
Reference in new issue
Block a user