feat(abut-rebar): ③ 교대 부재 목록 단순화 · 철근표 선택 / 전체 선택 / 선택만 보기

- 부재 목록: 교각처럼 이름 + 경고 수 배지만. 배근 타입 · 번호 · 다음 단계는 툴팁과 카드 설명 줄로
- 철근표: 선택 칸, 행 클릭 단독 선택, 전체 선택/해제, 선택만 보기 (교각과 같은 rebarSelection · rebarIsolate)
- 삽도: 여러 철근 강조, 선택만 보기면 나머지 숨김. B10 풍선은 B10-1 · B10-2를 함께 선택

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-04 15:56:57 +09:00
1 parent f4311d7f95
commit d7714f7ef2
3 files changed
+63 -41

No files matched your search

+46 -30
View File
@@ -31,7 +31,7 @@ import {
import { ABUT_FOOTING_REBAR_DEFAULT, abutFootingGeom, abutFootingRebar, ABUT_FOOTING_SIZES, type AbutFootingGeom, type AbutFootingRebar, type AbutFootingRebarSettings } from './core/abut/rebar/footing';
import { skewAngle } from './core/abut/rebar/skew-layout';
import { lapLength } from './core/abut/rebar/splice';
import { ABUT_FOOTING_COLORS, abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg } from './view/abut-footing-rebar-svg';
import { ABUT_FOOTING_COLORS, abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg, type AbutRebarPick } from './view/abut-footing-rebar-svg';
import { abutGroundIssues, supportTopEl, type AbutGround } from './core/abut/ground';
import { ABUT_SLAB_DEFAULT, resolveSlab, SLAB_FORM_LABEL, slabGradePct, slabSection, slabSlopeLabel, type SlabForm } from './core/abut/slab';
import { abutSlabPlanSvg, abutSlabSectionSvg } from './view/abut-slab-svg';
@@ -210,9 +210,8 @@ const state = {
rebarColor: 'role' as RebarColorMode,
/** Member tab on ③ (철근표). */
rebarMember: 'coping' as RebarRow['member'],
/** ③ 교대 (2026-11-04): 부재, 강조한 철근 번호, 보기 탭, 비고 펼치기. */
/** ③ 교대 (2026-11-04): 부재, 보기 탭, 비고 펼치기. 선택한 철근은 교각과 같은 rebarSelection · rebarIsolate. */
abutRebarMember: 'foot' as AbutRebarMemberId,
abutRebarPick: null as string | null,
abutRebarView: 'w' as 'w' | 'l' | 'plan',
abutRebarNotes: false,
/** Member card selected on ② (시안 ② 부재 목록). */
@@ -709,6 +708,7 @@ function abutRow(rec: AbutRecord, mode: Stage = 'shape'): HTMLLIElement {
name.addEventListener('click', () => {
state.abutActive = rec.name;
state.abutEditOpen = false;
state.rebarSelection = new Set();
setStatus(abutSummary(rec));
// Rebuilt first: the abutment and the camera frame are in the new scene (visibility.ts in ②).
setupScene();
@@ -3113,15 +3113,15 @@ function renderAbutRebar(rec: AbutRecord): void {
tabs.replaceChildren();
for (const m of ABUT_REBAR_MEMBERS) {
const li = el('li');
const btn = el('button', undefined, 'member');
// 교각 부재 목록처럼 이름과 경고 수만 (2026-11-04). 배근 타입 · 번호와 "다음 단계"는 카드 제목 줄에 있다.
const btn = el('button', m.label, 'member');
btn.type = 'button';
btn.title = `Type-1 · ${m.marks}${m.ready ? '' : ' · 다음 단계'}`;
btn.setAttribute('aria-pressed', String(m.id === state.abutRebarMember));
btn.append(el('span', m.label), el('span', ` Type-1 · ${m.marks}`, 'muted small'));
if (!m.ready) btn.append(el('span', '다음 단계', 'badge'));
else if (warn > 0) btn.append(el('span', String(warn), 'badge warn'));
if (m.ready && warn > 0) btn.append(el('span', String(warn), 'badge warn'));
btn.addEventListener('click', () => {
state.abutRebarMember = m.id;
state.abutRebarPick = null;
state.rebarSelection = new Set();
renderAll();
});
li.appendChild(btn);
@@ -3134,7 +3134,7 @@ function renderAbutRebar(rec: AbutRecord): void {
const member = ABUT_REBAR_MEMBERS.find((m) => m.id === state.abutRebarMember)!;
$('rebar-title').textContent = `${member.label} 배근`;
if (!member.ready) {
$('rebar-note').textContent = `${rec.name} · ${member.label} (${member.marks})`;
$('rebar-note').textContent = `${rec.name} · ${member.label} · Type-1 · ${member.marks} · 다음 단계`;
box.append(el('p', `${member.label} 배근은 다음 단계에서 추가합니다 (철근 기획서 확정, plan_abut AD4–AD8).`, 'muted'));
} else if (!built) {
$('rebar-note').textContent = `${rec.name} · 교대 모델을 만들 수 없어 배근하지 않습니다.`;
@@ -3157,7 +3157,9 @@ function renderAbutRebar(rec: AbutRecord): void {
function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutFootingGeom; result: AbutFootingRebar; skewK: number }): void {
const { geom, result: r, skewK } = built;
const s = r.settings;
const pick = state.abutRebarPick;
const pick: AbutRebarPick = { marks: state.rebarSelection, only: state.rebarIsolate };
// The B10 풍선 and 철근표 rows stand for B10-1 and B10-2 together.
const marksOf = (mark: string) => (mark === 'B10' ? r.bars.filter((b) => b.mark.startsWith('B10')).map((b) => b.mark) : [mark]);
const set = (patch: Partial<AbutFootingRebarSettings>) => {
try {
applyAbutRecord(applyAbutFootingRebar(rec, patch));
@@ -3166,8 +3168,11 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: {
renderAll();
}
};
// A 번호 풍선 or row click selects that bar alone; clicking it again clears it (교각 철근표와 같음).
const togglePick = (mark: string) => {
state.abutRebarPick = state.abutRebarPick === mark ? null : mark;
const marks = marksOf(mark);
const alone = state.rebarSelection.size === marks.length && marks.every((m) => state.rebarSelection.has(m));
state.rebarSelection = alone ? new Set() : new Set(marks);
renderAll();
};
// 보기 탭 + 전체 보기 + 강조 상태.
@@ -3183,22 +3188,14 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: {
});
head.append(b);
}
const all = el('button', '전체 보기', 'btn small');
all.type = 'button';
all.disabled = pick === null;
all.addEventListener('click', () => {
state.abutRebarPick = null;
renderAll();
});
head.append(all);
box.append(head);
const picked = pick ? r.bars.filter((b) => b.mark === pick || (pick === 'B10' && b.mark.startsWith('B10'))) : [];
const picked = r.bars.filter((b) => state.rebarSelection.has(b.mark));
box.append(
el(
'p',
pick === null
? '전체 표시 · 표의 번호나 도면의 번호 풍선을 누르면 그 철근만 강조합니다'
: `강조 중 · ${picked.map((b) => b.mark).join(' · ')}${picked[0] ? ` — ${picked[0].position}` : ''} · 같은 번호를 다시 누르거나 [전체 보기]로 해제`,
picked.length === 0
? '전체 표시 · 표의 행이나 도면의 번호 풍선을 누르면 그 철근만 강조합니다'
: `강조 중 · ${picked.map((b) => b.mark).join(' · ')}${picked.length === 1 ? ` — ${picked[0].position}` : ''}${state.rebarIsolate ? ' · 선택만 보기' : ''} · 철근표의 [전체 해제]로 해제`,
'muted small',
),
);
@@ -3312,9 +3309,10 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: {
});
bar.append(notes);
box.append(bar);
box.append(rebarSelectBar(r.bars.map((b) => b.mark)));
const table = el('table', undefined, 'grid-table rebar-table');
const hr = table.createTHead().insertRow();
for (const h of ['구분', '번호', '규격', '간격', '실간격', '형상', '길이 (mm)', '개수', '위치']) hr.append(el('th', h));
for (const h of ['선택', '구분', '번호', '규격', '간격', '실간격', '형상', '길이 (mm)', '개수', '위치']) hr.append(el('th', h));
const body = table.createTBody();
const roleColor: Record<string, string> = { 주철근: C.main, 보조: C.dist, '전단·간격재': C.shear, 측면: C.side };
// 철근별 간격 수정 (RB-AF10): the first row of each linked group takes the 실간격; B9 goes by the 배수 above.
@@ -3324,14 +3322,28 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: {
'B10-1': { key: 'sSide', base: s.sV },
};
for (const b of r.bars) {
const on = pick !== null && (b.mark === pick || (pick === 'B10' && b.mark.startsWith('B10')));
const on = state.rebarSelection.has(b.mark);
const tr = body.insertRow();
if (on) tr.classList.add('hl');
tr.addEventListener('click', () => togglePick(b.mark));
const check = el('input');
check.type = 'checkbox';
check.checked = on;
check.setAttribute('aria-label', `${b.mark} 선택`);
// 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(b.mark);
else next.delete(b.mark);
state.rebarSelection = next;
renderAll();
});
const checkCell = el('td');
checkCell.append(check);
const role = el('td');
role.innerHTML = `<span style="display:inline-block;width:22px;border-top:3px solid ${roleColor[b.role]};vertical-align:middle"></span> ${b.role}`;
const markCell = el('td', b.mark, 'bar-no');
markCell.style.cursor = 'pointer';
markCell.addEventListener('click', () => togglePick(b.mark.startsWith('B10') ? 'B10' : b.mark));
const actual = el('td');
const ov = overrideOf[b.mark];
if (ov) {
@@ -3341,23 +3353,27 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: {
input.value = b.actual;
input.style.width = '70px';
input.setAttribute('aria-label', `${b.mark} 묶음 실간격`);
input.addEventListener('click', (ev) => ev.stopPropagation());
input.addEventListener('change', () => set({ [ov.key]: Number(input.value) === ov.base ? undefined : Number(input.value) }));
actual.append(input);
if (b.modified) {
actual.append(el('span', ' 수정됨', 'muted small abut-note-warn'));
const back = el('button', '되돌리기', 'btn small');
back.type = 'button';
back.addEventListener('click', () => set({ [ov.key]: undefined }));
back.addEventListener('click', (ev) => {
ev.stopPropagation();
set({ [ov.key]: undefined });
});
actual.append(back);
}
} else {
actual.textContent = b.actual + (b.modified && b.mark !== 'B9' ? ' · 수정됨' : '');
}
tr.append(role, markCell, el('td', b.size), el('td', b.spacing), actual, el('td', b.shape), el('td', String(b.length)), el('td', String(b.count)), el('td', b.position));
tr.append(checkCell, role, markCell, el('td', b.size), el('td', b.spacing), actual, el('td', b.shape), el('td', String(b.length)), el('td', String(b.count)), el('td', b.position));
if (state.abutRebarNotes || on) {
const nr = body.insertRow();
const cell = el('td', `${b.mark} 비고 · ${b.note}`, 'muted small');
cell.colSpan = 9;
cell.colSpan = 10;
nr.append(cell);
}
}