feat(view): ②·③ 모델과 입력창/철근표를 상하·좌우로 전환하고 분할선으로 크기 조절

- 중앙 도구줄 '상하 | 좌우' 전환(② 도구줄 신설), 분할선 드래그·키보드(←→↑↓, Shift×5, Home/End)·더블클릭 기본값
- 상하: 모델 높이 180–560 px, 좌우: 모델 폭 30–70 %(모델 왼쪽), 모델 접기 시 창 전체 사용
- 중앙 열 900 px 미만·1100 px 이하 화면은 자동 상하 + 안내, 넓어지면 복귀
- 단계별 표시 설정을 localStorage에 저장(기존 pierzainer.review-layout 이관), 프로젝트 파일 미포함
- 오른쪽 창 묶음(.workspace-pane) 도입에 맞춰 기존 검증 스크립트 선택자 갱신
- 단위시험 4개, verify-workspace-layout.cjs Chrome 27개 검사

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 18:01:20 +09:00
1 parent ef596650cc
commit 7d36c4b64a
8 files changed
+389 -44

No files matched your search

+4 -33
View File
@@ -145,6 +145,7 @@ import type { Issue, PierInput, PierType, StrataRow } from './core/types';
import { buildPierScene, setRebarView, setRebarViewOn, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
import { buildAllPiers, type AllPiers } from './view/all-piers';
import { buildTerrainMapGroup, disposeTerrainMapGroup, setTerrainMapMode } from './view/terrain-map';
import { initWorkspaceLayout } from './view/workspace-layout';
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
interface Pier extends PierValues {
@@ -6383,28 +6384,10 @@ function restorePanel(id: string): void {
restorePanel('parts-info');
restorePanel('color-info');
/** Viewer sizing is a local display preference, separate from project/design data. */
/** Viewer sizing/arrangement is a local display preference, separate from project/design data. */
function initReviewWorkspace(): void {
const workspace = $('review-workspace');
const slider = $<HTMLInputElement>('review-model-height');
const toggle = $<HTMLButtonElement>('review-model-toggle');
let collapsed = false;
try {
const saved = JSON.parse(localStorage.getItem('pierzainer.review-layout') ?? 'null');
if (saved && Number.isFinite(saved.height) && saved.height >= 180 && saved.height <= 560) slider.value = String(saved.height);
collapsed = saved?.collapsed === true;
} catch { /* A blocked store must not prevent resizing. */ }
const apply = () => {
workspace.style.setProperty('--review-model-height', `${slider.value}px`);
workspace.classList.toggle('model-collapsed', collapsed);
$('review-model-height-value').textContent = `${slider.value} px`;
toggle.textContent = collapsed ? '모델 펼치기' : '모델 접기';
toggle.setAttribute('aria-expanded', String(!collapsed));
slider.disabled = collapsed;
try { localStorage.setItem('pierzainer.review-layout', JSON.stringify({ height: Number(slider.value), collapsed })); } catch { /* Display preference only. */ }
};
slider.addEventListener('input', apply);
toggle.addEventListener('click', () => { collapsed = !collapsed; apply(); });
initWorkspaceLayout($('plan-workspace'));
initWorkspaceLayout($('review-workspace'));
const jump = (selector: string) => {
const node = document.querySelector<HTMLElement>(selector);
if (!node) return;
@@ -6414,18 +6397,6 @@ function initReviewWorkspace(): void {
};
$('review-inputs').addEventListener('click', () => jump('#rebar-table .abut-rebar-inputs input, #rebar-table input[type="number"], #rebar-table select'));
$('review-table').addEventListener('click', () => jump('#rebar-table table.rebar-table'));
apply();
new ResizeObserver(() => {
if (workspace.clientHeight === 0 || !window.matchMedia('(min-width: 1101px)').matches) return;
const available = workspace.clientHeight - workspace.querySelector<HTMLElement>('.workspace-tools')!.offsetHeight - 264;
const max = Math.max(180, Math.min(560, Math.floor(available / 20) * 20));
const next = Math.min(Number(slider.value), max);
if (slider.max !== String(max) || slider.value !== String(next)) {
slider.max = String(max);
slider.value = String(next);
apply();
}
}).observe(workspace);
}
initReviewWorkspace();
+34 -4
View File
@@ -776,8 +776,21 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.grid > #viewer-block .canvas { flex: 1 1 auto; height: auto; min-height: 240px; }
/* ②–④: the model keeps a fixed band at the top; the table scrolls below it. */
.center-col { display: grid; grid-template-rows: clamp(280px, 48vh, 560px) minmax(0, 1fr); gap: 12px; }
.review-workspace { grid-template-rows: auto minmax(180px, var(--review-model-height, 240px)) minmax(0, 1fr); }
.review-workspace.model-collapsed { grid-template-rows: auto minmax(0, 1fr); }
/* ②·③ workspace: tools / model / splitter / pane (상하) or model | splitter | pane (좌우). */
.workspace { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(180px, var(--review-model-height, clamp(280px, 48vh, 560px))) 12px minmax(0, 1fr); gap: 0; }
.workspace > .workspace-tools { margin-bottom: 8px; }
.workspace.model-collapsed { grid-template-rows: auto minmax(0, 1fr); }
.workspace.layout-side { grid-template-columns: minmax(0, var(--model-width, 50%)) 12px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.workspace.layout-side > .workspace-tools { grid-column: 1 / -1; }
.workspace.layout-side > .viewer-slot { grid-column: 1; grid-row: 2; }
.workspace.layout-side > .workspace-splitter { grid-column: 2; grid-row: 2; }
.workspace.layout-side > .workspace-pane { grid-column: 3; grid-row: 2; }
.workspace.layout-side.model-collapsed { grid-template-columns: minmax(0, 1fr); }
.workspace.layout-side.model-collapsed > .workspace-pane { grid-column: 1 / -1; }
.workspace-pane { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 12px; }
.workspace-pane > .viewer { flex: none; }
.review-workspace > .workspace-pane { overflow: hidden; }
.review-workspace > .workspace-pane > .viewer { flex: 1 1 auto; min-height: 0; overflow: auto; }
.viewer-slot { min-height: 0; height: 100%; display: flex; flex-direction: column; }
.viewer-slot > .viewer { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.viewer-slot .canvas { flex: 1 1 auto; height: auto; min-height: 180px; }
@@ -793,11 +806,28 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.viewer-slot .canvas { height: 60vh; min-height: 420px; flex: none; }
.grid > .review-workspace { max-height: none; }
.review-workspace .viewer-slot .canvas { height: var(--review-model-height, 240px); min-height: 180px; }
.review-workspace > .viewer { max-height: 75vh; overflow: auto; }
.review-workspace .workspace-pane > .viewer { max-height: 75vh; overflow: auto; }
.workspace-pane { display: flex; flex-direction: column; gap: 16px; }
.workspace-splitter { display: none; }
.rebar-table thead th { position: sticky; top: 0; background: #ffffff; z-index: 1; }
}
.review-workspace.model-collapsed #slot-rebar { display: none; }
.workspace.model-collapsed > .viewer-slot, .workspace.model-collapsed > .workspace-splitter { display: none; }
/* ②·③ 배치 전환 · 분할선 (2026-11-09) */
.layout-switch { display: inline-flex; border: 1px solid var(--line, #d5dde6); border-radius: 8px; overflow: hidden; }
.layout-switch .btn { border: 0; border-radius: 0; min-width: 52px; }
.layout-switch .btn + .btn { border-left: 1px solid var(--line, #d5dde6); }
.layout-switch .btn[aria-pressed="true"] { background: #e8f1fb; color: #0b4f8a; font-weight: 700; }
.layout-note:empty { display: none; }
.workspace-splitter { position: relative; cursor: row-resize; touch-action: none; border-radius: 6px; outline: none; }
.workspace-splitter::after { content: ""; position: absolute; left: 50%; top: 50%; width: 48px; height: 4px; transform: translate(-50%, -50%); border-radius: 2px; background: #c3cfdb; transition: background .15s; }
.workspace.layout-side > .workspace-splitter { cursor: col-resize; }
.workspace.layout-side > .workspace-splitter::after { width: 4px; height: 48px; }
.workspace-splitter:hover::after, .workspace.is-resizing > .workspace-splitter::after { background: #2f7bc0; }
.workspace-splitter:focus-visible { box-shadow: inset 0 0 0 2px #2f7bc0; }
.workspace.is-resizing { user-select: none; cursor: row-resize; }
.workspace.layout-side.is-resizing { cursor: col-resize; }
/* 3D 영역 (승인 2026-10-23): 제목은 교각 목록으로. 좌측 스택(부재 표시 · 시점), 우측 스택(색 기준 · 원점)을 캔버스 위에 둠. */
#viewer-block { position: relative; }
+163
View File
@@ -0,0 +1,163 @@
/**
* ②·③ center workspace layout: model above the pane (stack) or beside it (side),
* with a draggable / keyboard splitter. A per-stage display preference kept in
* localStorage only — never part of the project file.
*/
export type WorkspaceMode = 'stack' | 'side';
export interface WorkspaceLayout { mode: WorkspaceMode; height: number | null; width: number; collapsed: boolean }
export const HEIGHT_MIN = 180, HEIGHT_MAX = 560, HEIGHT_STEP = 20;
export const WIDTH_MIN = 30, WIDTH_MAX = 70, WIDTH_STEP = 2, WIDTH_DEFAULT = 50;
/** Below this center-column width each side-by-side pane would be under ~440 px. */
export const SIDE_MIN_WIDTH = 900;
const DESKTOP = '(min-width: 1101px)';
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const snap = (v: number, step: number) => Math.round(v / step) * step;
export function defaultLayout(stage: string): WorkspaceLayout {
return { mode: 'stack', height: stage === 'rebar' ? 240 : null, width: WIDTH_DEFAULT, collapsed: false };
}
/** Parse a stored value defensively; the old ③ key `pierzainer.review-layout` held {height, collapsed}. */
export function parseLayout(stage: string, raw: unknown): WorkspaceLayout {
const base = defaultLayout(stage);
if (!raw || typeof raw !== 'object') return base;
const v = raw as Record<string, unknown>;
const height = typeof v.height === 'number' && Number.isFinite(v.height) && v.height >= HEIGHT_MIN && v.height <= HEIGHT_MAX ? v.height : base.height;
const width = typeof v.width === 'number' && Number.isFinite(v.width) ? clamp(v.width, WIDTH_MIN, WIDTH_MAX) : base.width;
return { mode: v.mode === 'side' ? 'side' : 'stack', height, width, collapsed: v.collapsed === true };
}
/** Side-by-side applies only when the user chose it and the column is wide enough on a desktop frame. */
export function effectiveMode(layout: WorkspaceLayout, columnWidth: number, desktop: boolean): { mode: WorkspaceMode; forced: boolean } {
if (layout.mode === 'side' && (!desktop || columnWidth < SIDE_MIN_WIDTH)) return { mode: 'stack', forced: true };
return { mode: layout.mode, forced: false };
}
const storageKey = (stage: string) => `pierzainer.workspace-layout.${stage}`;
function load(stage: string): WorkspaceLayout {
try {
const saved = localStorage.getItem(storageKey(stage));
if (saved) return parseLayout(stage, JSON.parse(saved));
if (stage === 'rebar') return parseLayout(stage, JSON.parse(localStorage.getItem('pierzainer.review-layout') ?? 'null'));
} catch { /* A blocked or corrupt store falls back to defaults. */ }
return defaultLayout(stage);
}
export function initWorkspaceLayout(workspace: HTMLElement): void {
const stage = workspace.dataset.stage ?? 'plan';
const tools = workspace.querySelector<HTMLElement>('.workspace-tools')!;
const slot = workspace.querySelector<HTMLElement>('.viewer-slot')!;
const splitter = workspace.querySelector<HTMLElement>('.workspace-splitter')!;
const slider = tools.querySelector<HTMLInputElement>('input[type="range"]')!;
const output = tools.querySelector<HTMLOutputElement>('output')!;
const sizeLabel = tools.querySelector<HTMLElement>('[data-size-label]')!;
const toggle = tools.querySelector<HTMLButtonElement>('button[aria-controls]')!;
const note = tools.querySelector<HTMLElement>('.layout-note')!;
const modeButtons = Array.from(tools.querySelectorAll<HTMLButtonElement>('[data-layout]'));
const layout = load(stage);
let heightMax = HEIGHT_MAX;
let current: WorkspaceMode = 'stack';
const save = () => { try { localStorage.setItem(storageKey(stage), JSON.stringify(layout)); } catch { /* Display preference only. */ } };
/** Without a saved height ② keeps its original responsive band; show what is on screen. */
const shownHeight = () => layout.height ?? clamp(snap(slot.getBoundingClientRect().height || 400, HEIGHT_STEP), HEIGHT_MIN, heightMax);
const apply = () => {
const desktop = window.matchMedia(DESKTOP).matches;
const { mode, forced } = effectiveMode(layout, workspace.clientWidth, desktop);
current = mode;
workspace.classList.toggle('layout-side', mode === 'side');
workspace.classList.toggle('model-collapsed', layout.collapsed);
if (layout.height == null) workspace.style.removeProperty('--review-model-height');
else workspace.style.setProperty('--review-model-height', `${Math.min(layout.height, heightMax)}px`);
workspace.style.setProperty('--model-width', `${layout.width}%`);
for (const b of modeButtons) b.setAttribute('aria-pressed', String(b.dataset.layout === layout.mode));
note.textContent = forced ? (desktop ? '화면이 좁아 상하로 표시 중' : '좁은 화면은 상하로 표시') : '';
toggle.textContent = layout.collapsed ? '모델 펼치기' : '모델 접기';
toggle.setAttribute('aria-expanded', String(!layout.collapsed));
const side = mode === 'side';
sizeLabel.textContent = side ? '모델 폭' : '모델 높이';
slider.min = String(side ? WIDTH_MIN : HEIGHT_MIN);
slider.max = String(side ? WIDTH_MAX : heightMax);
slider.step = String(side ? WIDTH_STEP : HEIGHT_STEP);
const value = side ? layout.width : Math.min(shownHeight(), heightMax);
slider.value = String(value);
output.textContent = side ? `${value}%` : `${value} px`;
slider.disabled = layout.collapsed;
splitter.hidden = layout.collapsed;
splitter.setAttribute('aria-orientation', side ? 'vertical' : 'horizontal');
splitter.setAttribute('aria-valuemin', slider.min);
splitter.setAttribute('aria-valuemax', slider.max);
splitter.setAttribute('aria-valuenow', String(value));
splitter.setAttribute('aria-valuetext', output.textContent);
splitter.title = side ? '끌어서 모델 폭 조절 · 두 번 클릭하면 기본값' : '끌어서 모델 높이 조절 · 두 번 클릭하면 기본값';
};
const setSize = (value: number) => {
if (current === 'side') layout.width = clamp(snap(value, WIDTH_STEP), WIDTH_MIN, WIDTH_MAX);
else layout.height = clamp(snap(value, HEIGHT_STEP), HEIGHT_MIN, heightMax);
apply(); save();
};
for (const b of modeButtons) b.addEventListener('click', () => { layout.mode = b.dataset.layout === 'side' ? 'side' : 'stack'; apply(); save(); });
slider.addEventListener('input', () => setSize(Number(slider.value)));
toggle.addEventListener('click', () => { layout.collapsed = !layout.collapsed; apply(); save(); });
splitter.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
splitter.setPointerCapture(event.pointerId);
workspace.classList.add('is-resizing');
const move = (e: PointerEvent) => {
if (current === 'side') {
const r = workspace.getBoundingClientRect();
setSize(((e.clientX - r.left) / r.width) * 100);
} else {
setSize(e.clientY - slot.getBoundingClientRect().top);
}
};
const end = () => {
workspace.classList.remove('is-resizing');
splitter.removeEventListener('pointermove', move);
splitter.removeEventListener('pointerup', end);
splitter.removeEventListener('pointercancel', end);
};
splitter.addEventListener('pointermove', move);
splitter.addEventListener('pointerup', end);
splitter.addEventListener('pointercancel', end);
});
splitter.addEventListener('keydown', (event) => {
const side = current === 'side';
const step = side ? WIDTH_STEP : HEIGHT_STEP;
const now = side ? layout.width : shownHeight();
const grow = side ? ['ArrowRight', 'ArrowDown'] : ['ArrowDown', 'ArrowRight'];
const shrink = side ? ['ArrowLeft', 'ArrowUp'] : ['ArrowUp', 'ArrowLeft'];
let next: number | null = null;
if (grow.includes(event.key)) next = now + step * (event.shiftKey ? 5 : 1);
else if (shrink.includes(event.key)) next = now - step * (event.shiftKey ? 5 : 1);
else if (event.key === 'Home') next = side ? WIDTH_MIN : HEIGHT_MIN;
else if (event.key === 'End') next = side ? WIDTH_MAX : heightMax;
if (next == null) return;
event.preventDefault();
setSize(next);
});
splitter.addEventListener('dblclick', () => {
if (current === 'side') layout.width = WIDTH_DEFAULT;
else layout.height = defaultLayout(stage).height;
apply(); save();
});
// Keep the stacked model band within the visible column and re-evaluate the side-by-side width rule.
new ResizeObserver(() => {
if (workspace.clientHeight === 0) return;
if (window.matchMedia(DESKTOP).matches) {
const available = workspace.clientHeight - tools.offsetHeight - 264;
heightMax = Math.max(HEIGHT_MIN, Math.min(HEIGHT_MAX, Math.floor(available / HEIGHT_STEP) * HEIGHT_STEP));
} else heightMax = HEIGHT_MAX;
apply();
}).observe(workspace);
window.matchMedia(DESKTOP).addEventListener('change', apply);
apply();
}