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:
1 parent
f4311d7f95
commit
d7714f7ef2
3 files changed
+63
-41
No files matched your search
+46
-30
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 교대 저판 배근 삽도 (2026-11-04, 시안 ③ 확대기초 카드, 교대확대기초철근.md §10): 교대축 직각 단면 (폭 W), 교대축 단면 (길이 L),
|
||||
* 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 강조할 번호는 `pick`으로 받는다.
|
||||
* 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 선택한 번호는 `pick`(철근표 번호 집합)으로, 선택만 보기는 `only`로 받는다.
|
||||
* 색은 역할 기준: 주철근 B1–B4 #0F5C66, 보조 B5–B8 #B4531F, 측면 B10 #2E7D32 점선, 전단 B9 #6B3FA0.
|
||||
*/
|
||||
import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing';
|
||||
@@ -14,21 +14,26 @@ const WALL = '#d9dee4';
|
||||
const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="${size}" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||||
|
||||
/** Opacity of one bar group: dimmed when another bar is picked. B10 picks cover B10-1 and B10-2. */
|
||||
function opacity(pick: string | null, marks: string[]): number {
|
||||
if (!pick) return 1;
|
||||
return marks.some((m) => m === pick || (pick === 'B10' && m.startsWith('B10'))) ? 1 : 0.15;
|
||||
/** Selected bars (철근표 번호) and 선택만 보기. An empty selection shows every bar. */
|
||||
export type AbutRebarPick = { marks: ReadonlySet<string>; only: boolean };
|
||||
const NO_PICK: AbutRebarPick = { marks: new Set(), only: false };
|
||||
|
||||
/** Opacity of one bar group: dimmed (or hidden with 선택만 보기) when other bars are selected. */
|
||||
function opacity(pick: AbutRebarPick, marks: string[]): number {
|
||||
if (pick.marks.size === 0) return 1;
|
||||
return marks.some((m) => pick.marks.has(m)) ? 1 : pick.only ? 0 : 0.15;
|
||||
}
|
||||
|
||||
/** A clickable 번호 풍선. */
|
||||
function balloon(x: number, y: number, mark: string, color: string, pick: string | null): string {
|
||||
const on = pick === mark;
|
||||
function balloon(x: number, y: number, mark: string, color: string, pick: AbutRebarPick): string {
|
||||
// The B10 풍선 stands for B10-1 and B10-2.
|
||||
const on = [...pick.marks].some((m) => m === mark || (mark === 'B10' && m.startsWith('B10')));
|
||||
const r = mark.length > 3 ? 15 : 12;
|
||||
return `<g data-pick="${mark}" style="cursor:pointer" role="button" tabindex="0" aria-label="${mark} 강조"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="${on ? color : '#fff'}" stroke="${color}" stroke-width="1.4"/>${text(x, y + 4, mark, 'middle', on ? '#fff' : color, 10)}</g>`;
|
||||
}
|
||||
|
||||
/** 교대축 직각 단면 (폭 W): 왼쪽 배면(뒷굽) → 오른쪽 전면(앞굽). B1–B4 선, B5–B8 점, B10-1 점, B9 연직선. */
|
||||
export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: string | null = null): string {
|
||||
export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const k = 0.12;
|
||||
const x0 = 60;
|
||||
const y0 = 46;
|
||||
@@ -82,7 +87,7 @@ export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar,
|
||||
}
|
||||
|
||||
/** 교대축 단면 (길이 L, 가로 축척 0.4배): B5–B8 선(이음 위치), B1–B4 점, B10-2 점. 왼쪽 IP3 쪽. */
|
||||
export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: string | null = null): string {
|
||||
export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const kx = 0.045;
|
||||
const kz = 0.12;
|
||||
const x0 = 50;
|
||||
@@ -132,7 +137,7 @@ export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar,
|
||||
* 상면 평면: skew 평행사변형, 상면 B1·B3 (옵션에 따라 skew 선과 나란히 / 교대축 직각, 옵션 ② B1-S), B5·B7, B9, 벽체 밑 띠.
|
||||
* `skewK` = 날개벽 방향의 s / t 비 (장면의 abutSkewK): s(x) = s + skewK · x. 위 = 전면, 왼쪽 = IP3 끝 (s = L).
|
||||
*/
|
||||
export function abutFootingRebarPlanSvg(g: AbutFootingGeom, r: AbutFootingRebar, skewK: number, pick: string | null = null): string {
|
||||
export function abutFootingRebarPlanSvg(g: AbutFootingGeom, r: AbutFootingRebar, skewK: number, pick: AbutRebarPick = NO_PICK): string {
|
||||
const k = 0.045;
|
||||
const shiftMax = Math.abs(skewK) * g.W;
|
||||
const sMin = Math.min(0, skewK * g.W);
|
||||
|
||||
@@ -207,7 +207,8 @@ describe('③ 저판 배근 입력 저장 · 삽도', () => {
|
||||
for (const s of ['100 · 175 · 27@150=4.050 · 175 · 100', '100 · 175 · 5@150=750 · 2.400 · 6@150=900 · 175 · 100', '150 · 3@200=600']) expect(w).toContain(s);
|
||||
for (const m of ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B9', 'B10']) expect(w).toContain(`data-pick="${m}"`);
|
||||
expect(w).not.toContain('opacity="0.15"');
|
||||
expect(abutFootingSectionWSvg(geom, A1, 'B5')).toContain('opacity="0.15"');
|
||||
expect(abutFootingSectionWSvg(geom, A1, { marks: new Set(['B5']), only: false })).toContain('opacity="0.15"');
|
||||
expect(abutFootingSectionWSvg(geom, A1, { marks: new Set(['B5']), only: true })).toContain('opacity="0"');
|
||||
expect(abutFootingSectionLSvg(geom, A1)).toContain('100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4');
|
||||
expect(abutFootingSectionLSvg(geom, A1)).toContain('B5·B6 이음 l_s 955');
|
||||
const opt2 = abutFootingRebar(geom, { dir: 'perp' });
|
||||
|
||||
Reference in new issue
Block a user