feat(view): collapsible 시점 menu, origin toggle, and remembered panel states on the 3D band

The title stays. The view buttons become a menu that shows the current view and closes on a choice, Esc or an outside
click. The origin note is an ⓘ toggle. 부재 표시 and 색 기준 are collapsed by default and show their state in the summary
(e.g. 부재 표시 7/7 ▾, 색 기준: 역할 ▾); their open state is remembered per viewer, with guarded storage access.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-23 11:24:43 +09:00
1 parent 678215267c
commit e66c259d1e
3 files changed
+76 -7

No files matched your search

+10 -6
View File
@@ -74,16 +74,20 @@
<section class="panel viewer" id="viewer-block" aria-labelledby="viewer-title">
<div class="viewer-head">
<h2 id="viewer-title">교각 3D 뷰</h2>
<div id="views" class="views" role="group" aria-label="시점"></div>
<span id="origin" class="muted"></span>
<div class="views-menu" id="views-menu">
<button id="views-toggle" class="btn views-toggle" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="views">시점</button>
<div id="views" class="views views-list" role="group" aria-label="시점" hidden></div>
</div>
<button id="origin-toggle" class="info-btn" type="button" aria-label="원점 표기" aria-expanded="false" aria-controls="origin">ⓘ</button>
<span id="origin" class="origin-pop muted" hidden></span>
</div>
<div id="canvas" class="canvas" aria-label="교각 3D 뷰"></div>
<details class="info-panel parts-info" open>
<summary>부재 표시</summary>
<details class="info-panel parts-info" id="parts-info">
<summary>부재 표시 <span id="parts-count" class="panel-state"></span></summary>
<div id="parts"></div>
</details>
<details class="info-panel color-info" id="color-info" open hidden>
<summary>색 기준</summary>
<details class="info-panel color-info" id="color-info" hidden>
<summary>색 기준 <span id="color-state" class="panel-state"></span></summary>
<div class="rebar-color" id="rebar-color" role="group" aria-label="철근 색 기준">
<button type="button" data-color="role" aria-pressed="true">역할</button>
<button type="button" data-color="diameter" aria-pressed="false">직경</button>
+55
View File
@@ -244,6 +244,9 @@ function setView(kind: ViewKind): void {
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;
@@ -445,6 +448,7 @@ function renderDefaults(): void {
}
function renderAll(): void {
updatePartsCount();
$('status').textContent = state.piers.length ? '' : '엑셀을 불러오면 교각 형상을 생성합니다.';
renderHeader();
renderPierList();
@@ -810,6 +814,8 @@ const REBAR_MEMBERS: { id: RebarRow['member']; label: string }[] = [
/** 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')) {
@@ -1769,3 +1775,52 @@ if (new URLSearchParams(window.location.search).get('sample') === '1') {
})
.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);
}
});
+11 -1
View File
@@ -782,5 +782,15 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
/* 좁은 창 (601–1100px, 승인 2026-10-23): 상단 띠에서 시점 버튼과 원점 표기를 오른쪽으로 */
@media (min-width: 601px) and (max-width: 1100px) {
#viewer-block .viewer-head #views { margin-left: auto; }
#viewer-block .viewer-head #views-menu { margin-left: auto; }
}
/* 3D band (승인 2026-10-23): view menu, origin popover, collapsed panel summaries */
#viewer-block .views-menu { position: relative; margin-left: auto; }
#viewer-block .views-toggle { min-height: 44px; font-weight: 700; background: #eef3f8; border: 1px solid #c3cbd4; }
#viewer-block #views.views-list { position: absolute; top: 100%; right: 0; z-index: 10; display: flex; flex-direction: column; gap: 0; min-width: 120px; padding: 4px; margin-top: 4px; background: #ffffff; border: 1px solid #c3cbd4; border-radius: 6px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15); }
#viewer-block #views.views-list .btn { min-height: 44px; justify-content: flex-start; text-align: left; border: 0; background: transparent; }
#viewer-block #views.views-list .btn[aria-pressed='true'] { background: #1f5fa8; color: #ffffff; }
#viewer-block .info-btn { min-width: 44px; min-height: 44px; border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; font-size: 16px; cursor: pointer; }
#viewer-block .origin-pop { position: absolute; top: 100%; right: 8px; z-index: 10; margin-top: 4px; padding: 6px 10px; background: #ffffff; border: 1px solid #c3cbd4; border-radius: 6px; font-size: 12px; white-space: nowrap; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); }
#viewer-block .panel-state { font-weight: 400; color: #55606e; margin-left: 6px; font-size: 12px; }