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:
1 parent
ef596650cc
commit
7d36c4b64a
8 files changed
+389
-44
No files matched your search
+26
-3
@@ -196,8 +196,22 @@
|
||||
<ul id="members" class="members"></ul>
|
||||
</aside>
|
||||
|
||||
<div class="center-col">
|
||||
<div class="center-col workspace" id="plan-workspace" data-stage="plan">
|
||||
<div class="workspace-tools" aria-label="계획 화면 배치 조절">
|
||||
<div class="layout-switch" role="group" aria-label="모델과 입력창 배치">
|
||||
<button type="button" class="btn small" data-layout="stack" aria-pressed="true">상하</button>
|
||||
<button type="button" class="btn small" data-layout="side" aria-pressed="false">좌우</button>
|
||||
</div>
|
||||
<label class="model-height-control" for="plan-model-height"><span data-size-label>모델 높이</span>
|
||||
<input id="plan-model-height" type="range" min="180" max="560" step="20" value="400" />
|
||||
<output id="plan-model-height-value" for="plan-model-height">400 px</output>
|
||||
</label>
|
||||
<button id="plan-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-plan">모델 접기</button>
|
||||
<span class="layout-note muted small" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="viewer-slot" id="slot-plan"></div>
|
||||
<div class="workspace-splitter" role="separator" tabindex="0" aria-controls="slot-plan" aria-label="모델 크기 조절"></div>
|
||||
<div class="workspace-pane">
|
||||
<!-- ② 교대 부재 카드 (시안, 2026-11-03): the card of the row picked in 부재 · 교대, under the 3D view. -->
|
||||
<section id="abut-panel-plan" class="panel viewer abut-card" hidden aria-labelledby="abut-panel-plan-title">
|
||||
<div class="viewer-head">
|
||||
@@ -260,6 +274,7 @@
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="panel results" aria-labelledby="changes-title">
|
||||
@@ -298,22 +313,30 @@
|
||||
<ul id="rebar-tabs" class="members"></ul>
|
||||
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
||||
</aside>
|
||||
<div class="center-col review-workspace" id="review-workspace">
|
||||
<div class="center-col workspace review-workspace" id="review-workspace" data-stage="rebar">
|
||||
<div class="workspace-tools" aria-label="검토 공간 조절">
|
||||
<label class="model-height-control" for="review-model-height">모델 높이
|
||||
<div class="layout-switch" role="group" aria-label="모델과 철근표 배치">
|
||||
<button type="button" class="btn small" data-layout="stack" aria-pressed="true">상하</button>
|
||||
<button type="button" class="btn small" data-layout="side" aria-pressed="false">좌우</button>
|
||||
</div>
|
||||
<label class="model-height-control" for="review-model-height"><span data-size-label>모델 높이</span>
|
||||
<input id="review-model-height" type="range" min="180" max="560" step="20" value="240" />
|
||||
<output id="review-model-height-value" for="review-model-height">240 px</output>
|
||||
</label>
|
||||
<button id="review-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-rebar">모델 접기</button>
|
||||
<button id="review-inputs" class="btn small" type="button">입력 보기</button>
|
||||
<button id="review-table" class="btn small" type="button">철근표 보기</button>
|
||||
<span class="layout-note muted small" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="viewer-slot" id="slot-rebar"></div>
|
||||
<div class="workspace-splitter" role="separator" tabindex="0" aria-controls="slot-rebar" aria-label="모델 크기 조절"></div>
|
||||
<div class="workspace-pane">
|
||||
<section class="panel viewer" aria-labelledby="rebar-title">
|
||||
<div class="viewer-head"><h2 id="rebar-title">철근표</h2><button id="rebar-materials" class="btn-o" type="button">재료표·수량산출 보기</button></div>
|
||||
<p id="rebar-note" class="num"></p>
|
||||
<div id="rebar-table"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="panel results" aria-labelledby="rebar-issues-title">
|
||||
<h2 id="rebar-issues-title">알림</h2>
|
||||
|
||||
@@ -40,10 +40,10 @@ const delay=ms=>new Promise(r=>setTimeout(r,ms));let ws;
|
||||
await click('#review-tasks button');report.taskJump=await ev(`({focused:document.activeElement?.innerText,selected:document.querySelector('#rebar-table').innerText.includes('강조 중')})`);
|
||||
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('확대기초')).click()`);
|
||||
await ev(`document.querySelector('#review-model-height').value='180';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);
|
||||
await click('#review-inputs');report.inputJump=await ev(`({focused:document.activeElement?.id,scroll:document.querySelector('#review-workspace > .viewer').scrollTop})`);
|
||||
await click('#review-inputs');report.inputJump=await ev(`({focused:document.activeElement?.id,scroll:document.querySelector('#review-workspace .workspace-pane > .viewer').scrollTop})`);
|
||||
await click('#review-model-toggle');await click('#review-table');
|
||||
await ev(`document.querySelector('#view-rebar > .results').scrollTop=250`);await cap('05-collapsed-table-material-1280');
|
||||
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
|
||||
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace .workspace-pane > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
|
||||
report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
await send('Page.close');ws.close();
|
||||
})().catch(e=>{report.failure=String(e.stack||e);fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.error(e);if(ws)ws.close();process.exitCode=1});
|
||||
@@ -56,7 +56,7 @@ async function main() {
|
||||
await until(`!document.getElementById('dlg-apply').disabled && document.getElementById('import-abut-A1')?.checked === true`);
|
||||
};
|
||||
const member = async (label) => { await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); await delay(100); };
|
||||
const lower = () => ev(`document.querySelector('#review-workspace > section').getBoundingClientRect().height`);
|
||||
const lower = () => ev(`document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect().height`);
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||
@@ -94,7 +94,7 @@ async function main() {
|
||||
report.layoutCollapsed = await lower();
|
||||
check('collapse adds model area to evidence', report.layoutCollapsed > report.layoutResized + 150);
|
||||
await click(`document.getElementById('review-table')`);
|
||||
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace > section').getBoundingClientRect().top - 2`));
|
||||
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect().top - 2`));
|
||||
await shot('03-table-laptop');
|
||||
await click(`document.getElementById('review-inputs')`);
|
||||
check('input shortcut preserves keyboard tab order', await ev(`document.activeElement.matches('#rebar-table input') && document.activeElement.tabIndex === 0`));
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
|
||||
// ②·③ center workspace: stacked ↔ side-by-side, splitter drag/keyboard, persistence, width fallback.
|
||||
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const out = path.resolve(root, '.verify/workspace-layout-20261109');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [], metrics: {} };
|
||||
let ws, send;
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
let target;
|
||||
(async () => {
|
||||
target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
||||
let seq = 0; const pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const m = JSON.parse(data), p = pending.get(m.id);
|
||||
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
|
||||
if (m.method === 'Page.javascriptDialogOpening') ws.send(JSON.stringify({ id: 900000 + (++seq), method: 'Page.handleJavaScriptDialog', params: { accept: true } }));
|
||||
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
};
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 8000);
|
||||
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const ev = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async (expression, tries = 120) => {
|
||||
for (let i = 0; i < tries; i++) { try { if (await ev(expression)) return; } catch { /* navigating */ } await delay(150); }
|
||||
throw new Error('Wait ' + expression);
|
||||
};
|
||||
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
|
||||
const shot = async name => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
|
||||
const viewport = (width, height) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
|
||||
const rects = ws_ => ev(`(()=>{const w=document.getElementById('${ws_}'),r=e=>{const b=e.getBoundingClientRect();return{l:Math.round(b.left),t:Math.round(b.top),r:Math.round(b.right),b:Math.round(b.bottom),w:Math.round(b.width),h:Math.round(b.height)};};const c=document.querySelector('#viewer-block canvas');return{ws:r(w),slot:r(w.querySelector('.viewer-slot')),split:r(w.querySelector('.workspace-splitter')),pane:r(w.querySelector('.workspace-pane')),canvas:c?r(c):null,side:w.classList.contains('layout-side'),note:w.querySelector('.layout-note').textContent,label:w.querySelector('[data-size-label]').textContent,value:w.querySelector('output').textContent};})()`);
|
||||
const drag = async (sel, dx, dy) => {
|
||||
const b = await ev(`(()=>{const r=document.querySelector('${sel}').getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};})()`);
|
||||
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: b.x, y: b.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
for (let i = 1; i <= 5; i++) await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: b.x + dx * i / 5, y: b.y + dy * i / 5, button: 'left', buttons: 1 });
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: b.x + dx, y: b.y + dy, button: 'left', buttons: 0, clickCount: 1 });
|
||||
await delay(250);
|
||||
};
|
||||
const key = async (k, code, vk) => { for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk }); await delay(120); };
|
||||
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await viewport(1600, 1000);
|
||||
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||
await ev(`Object.keys(localStorage).filter(k=>k.startsWith('pierzainer.workspace-layout')||k==='pierzainer.review-layout').forEach(k=>localStorage.removeItem(k))`);
|
||||
await send('Page.reload'); await delay(2500);
|
||||
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||
await click(`document.querySelector('[data-card="excel"] button')`);
|
||||
await ev(`document.getElementById('project-name').value='배치 검증';document.getElementById('project-name').dispatchEvent(new Event('input'));`);
|
||||
const doc = await send('DOM.getDocument');
|
||||
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#file' });
|
||||
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] });
|
||||
await until(`!document.getElementById('dlg-apply').disabled`);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await delay(800);
|
||||
|
||||
// ② 계획
|
||||
await click(`document.getElementById('nav-plan')`); await delay(600);
|
||||
let r = await rects('plan-workspace');
|
||||
check('② default is stacked: model above the input pane', !r.side && r.slot.b <= r.pane.t && r.slot.w === r.pane.w);
|
||||
check('② layout switch shows 상하 pressed', await ev(`document.querySelector('#plan-workspace [data-layout="stack"]').getAttribute('aria-pressed')==='true'`));
|
||||
await shot('01-plan-stack');
|
||||
await click(`document.querySelector('#plan-workspace [data-layout="side"]')`); await delay(500);
|
||||
r = await rects('plan-workspace'); report.metrics.planSide = r;
|
||||
check('② 좌우: model left, pane right, same top', r.side && r.slot.r <= r.pane.l && Math.abs(r.slot.t - r.pane.t) <= 1);
|
||||
check('② 좌우 starts near 50 % and shows width label', Math.abs(r.slot.w / r.ws.w - 0.5) < 0.03 && r.label === '모델 폭' && r.value === '50%');
|
||||
check('② 3D canvas follows the narrower slot', r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40 && r.canvas.h > 300);
|
||||
check('② pane scrolls independently', await ev(`(()=>{const p=document.querySelector('#plan-workspace .workspace-pane');return getComputedStyle(p).overflowY==='auto';})()`));
|
||||
await shot('02-plan-side');
|
||||
await drag('#plan-workspace .workspace-splitter', -Math.round(r.ws.w * 0.1), 0);
|
||||
r = await rects('plan-workspace');
|
||||
check('② splitter drag narrows model to about 40 %', r.value === '40%' && Math.abs(r.slot.w / r.ws.w - 0.4) < 0.03);
|
||||
await ev(`document.querySelector('#plan-workspace .workspace-splitter').focus()`);
|
||||
await key('ArrowRight', 'ArrowRight', 39);
|
||||
check('② splitter keyboard step (+2 %)', (await rects('plan-workspace')).value === '42%' && await ev(`document.querySelector('#plan-workspace .workspace-splitter').getAttribute('aria-valuenow')==='42'`));
|
||||
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 });
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 });
|
||||
await ev(`document.querySelector('#plan-workspace .workspace-splitter').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))`); await delay(200);
|
||||
check('② double-click resets to 50 %', (await rects('plan-workspace')).value === '50%');
|
||||
await click(`document.getElementById('plan-model-toggle')`); await delay(300);
|
||||
r = await rects('plan-workspace');
|
||||
check('② collapsed model: pane takes the full width', r.slot.w === 0 && Math.abs(r.pane.w - r.ws.w) <= 2);
|
||||
await click(`document.getElementById('plan-model-toggle')`); await delay(300);
|
||||
|
||||
// ③ 배근 stays independent
|
||||
await click(`document.getElementById('nav-rebar')`); await delay(700);
|
||||
r = await rects('review-workspace');
|
||||
check('③ preference is independent of ② (still stacked, 240 px)', !r.side && r.value === '240 px' && r.slot.b <= r.pane.t);
|
||||
await drag('#review-workspace .workspace-splitter', 0, 100);
|
||||
r = await rects('review-workspace');
|
||||
check('③ stacked splitter drag grows model height', r.value === '340 px' && Math.abs(r.slot.h - 340) <= 2);
|
||||
await click(`document.querySelector('#review-workspace [data-layout="side"]')`); await delay(500);
|
||||
r = await rects('review-workspace'); report.metrics.rebarSide = r;
|
||||
check('③ 좌우: model left, rebar table right', r.side && r.slot.r <= r.pane.l && r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40);
|
||||
await click(`document.getElementById('review-table')`); await delay(300);
|
||||
check('③ 철근표 shortcut still reveals the table in 좌우', await ev(`(()=>{const t=document.activeElement,p=document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect();return t.matches('#rebar-table table.rebar-table')&&t.getBoundingClientRect().top>=p.top-2;})()`));
|
||||
await shot('03-rebar-side');
|
||||
|
||||
// persistence
|
||||
await send('Page.reload'); await delay(2500); await until(`typeof window.loadBytesToApp==='function'`); await delay(800);
|
||||
check('preferences persist per stage after reload', await ev(`(()=>{const p=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.plan')),q=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.rebar'));return p.mode==='side'&&p.width===50&&q.mode==='side'&&q.height===340;})()`));
|
||||
await until(`typeof window.openSampleProject==='function'`); await ev('window.openSampleProject()'); await delay(1500);
|
||||
await click(`document.getElementById('nav-plan')`); await delay(600);
|
||||
check('② restored 좌우 when shown after reload', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`));
|
||||
await click(`document.getElementById('nav-rebar')`); await delay(600);
|
||||
check('③ restored 좌우 and 340 px height after reload', await ev(`document.getElementById('review-workspace').classList.contains('layout-side')&&getComputedStyle(document.getElementById('review-workspace')).getPropertyValue('--review-model-height').trim()==='340px'`));
|
||||
await click(`document.getElementById('nav-plan')`); await delay(400);
|
||||
|
||||
// width fallback
|
||||
await viewport(1280, 800); await delay(500);
|
||||
check('1280 px: side request falls back to stacked with a note', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&w.querySelector('.layout-note').textContent.includes('좁아')&&w.querySelector('[data-layout="side"]').getAttribute('aria-pressed')==='true';})()`));
|
||||
await viewport(1920, 1080); await delay(500);
|
||||
check('1920 px: side-by-side restored automatically', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`));
|
||||
await viewport(1000, 900); await delay(500);
|
||||
check('1000 px (mobile frame): stacked, splitter hidden', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&getComputedStyle(w.querySelector('.workspace-splitter')).display==='none';})()`));
|
||||
for (const width of [1280, 1600, 1920]) {
|
||||
await viewport(width, 1000); await delay(300);
|
||||
check(`${width} px: no horizontal page scroll`, await ev(`document.documentElement.scrollWidth<=innerWidth`));
|
||||
}
|
||||
check('no runtime exceptions', report.errors.length === 0);
|
||||
report.result = 'PASS';
|
||||
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ result: report.result, failure: report.failure, errors: report.errors }));
|
||||
if (target?.id) fetch('http://127.0.0.1:9444/json/close/' + target.id).catch(() => {});
|
||||
ws?.close();
|
||||
});
|
||||
+4
-33
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defaultLayout, effectiveMode, parseLayout, SIDE_MIN_WIDTH } from '../src/view/workspace-layout';
|
||||
|
||||
describe('workspace layout preference', () => {
|
||||
it('defaults: stacked, ③ model 240 px, ② keeps its responsive band', () => {
|
||||
expect(defaultLayout('rebar')).toEqual({ mode: 'stack', height: 240, width: 50, collapsed: false });
|
||||
expect(defaultLayout('plan').height).toBeNull();
|
||||
});
|
||||
it('migrates the old ③ {height, collapsed} value', () => {
|
||||
expect(parseLayout('rebar', { height: 360, collapsed: true })).toEqual({ mode: 'stack', height: 360, width: 50, collapsed: true });
|
||||
});
|
||||
it('rejects out-of-range or corrupt values', () => {
|
||||
expect(parseLayout('rebar', { mode: 'grid', height: 9999, width: 95 })).toEqual({ mode: 'stack', height: 240, width: 70, collapsed: false });
|
||||
expect(parseLayout('plan', 'x')).toEqual(defaultLayout('plan'));
|
||||
});
|
||||
it('side-by-side only on a wide desktop column, otherwise stacked and marked forced', () => {
|
||||
const side = { ...defaultLayout('plan'), mode: 'side' as const };
|
||||
expect(effectiveMode(side, SIDE_MIN_WIDTH, true)).toEqual({ mode: 'side', forced: false });
|
||||
expect(effectiveMode(side, SIDE_MIN_WIDTH - 1, true)).toEqual({ mode: 'stack', forced: true });
|
||||
expect(effectiveMode(side, 1400, false)).toEqual({ mode: 'stack', forced: true });
|
||||
expect(effectiveMode(defaultLayout('plan'), 400, true)).toEqual({ mode: 'stack', forced: false });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user