뷰어: 모바일 최적화 (하단 시트 패널, 터치 조작, 핀치 줌)
좁은 화면에서는 패널을 하단 시트로 바꾸고 접기·펼치기를 추가한다. 마우스 이벤트를 포인터 이벤트로 바꿔 한 손가락 회전, 두 손가락 핀치 줌, 탭 측정을 지원한다. 버튼 터치 영역을 키우고 캔버스의 기본 스크롤을 막는다. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d1c0965ce
commit
54d4b58a2a
1 file changed
+64
-12
+64
-12
@@ -17,11 +17,25 @@
|
||||
#measureLabel { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); background: rgba(13,31,45,0.92); color: #ffd166; padding: 8px 14px; border-radius: 6px; font-size: 14px; display: none; pointer-events: none; z-index: 5; }
|
||||
#panel button { margin-top: 8px; width: 100%; padding: 6px; background: #10b981; color: #0d1f2d; border: none; border-radius: 4px; font-weight: 700; cursor: pointer; }
|
||||
#hint { position: fixed; bottom: 8px; left: 10px; color: #9fb3c8; font-size: 12px; }
|
||||
canvas { touch-action: none; }
|
||||
#panel .phead { display: flex; justify-content: space-between; align-items: center; }
|
||||
#panel .ptoggle { display: none; margin: 0; width: auto; padding: 4px 10px; font-size: 12px; }
|
||||
@media (max-width: 720px) {
|
||||
#panel { top: auto; bottom: 0; left: 0; right: 0; min-width: 0; max-width: none; max-height: 48vh; overflow-y: auto; border-radius: 12px 12px 0 0; padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); font-size: 13px; }
|
||||
#panel .ptoggle { display: inline-block; }
|
||||
#panel.collapsed > *:not(.phead) { display: none; }
|
||||
#panel button.vb { min-height: 40px; font-size: 14px; }
|
||||
#panel button { min-height: 40px; }
|
||||
#panel input[type=checkbox] { width: 18px; height: 18px; }
|
||||
#panel input[type=range] { height: 28px; }
|
||||
#hint { display: none; }
|
||||
#measureLabel { top: 8px; font-size: 13px; max-width: 92vw; box-sizing: border-box; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="panel">
|
||||
<h4>부재 구성 (규격)</h4>
|
||||
<div class="phead"><h4>부재 구성 (규격)</h4><button id="btnPanel" class="ptoggle">접기</button></div>
|
||||
<div id="counts">로딩 중...</div>
|
||||
<button id="btnFit">줌핏 (보이는 부재)</button>
|
||||
<div class="vrow">
|
||||
@@ -36,7 +50,7 @@
|
||||
</div>
|
||||
<label style="display:block;margin-top:6px;cursor:pointer"><input type="checkbox" id="chkExc" checked> 굴착면 표시 (벽체 내측면)</label>
|
||||
</div>
|
||||
<div id="hint">드래그: 회전 · 휠: 줌(커서 방향) · 우클릭 드래그: 이동</div>
|
||||
<div id="hint">드래그: 회전 · 휠·두 손가락: 줌 · 우클릭 드래그: 이동</div>
|
||||
<canvas id="canvas"></canvas>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script>
|
||||
@@ -203,6 +217,11 @@
|
||||
.catch(err => { countsEl.textContent = '오류: ' + err.message; });
|
||||
|
||||
document.getElementById('btnFit').addEventListener('click', fitVisible);
|
||||
const panelEl = document.getElementById('panel');
|
||||
document.getElementById('btnPanel').addEventListener('click', e => {
|
||||
const collapsed = panelEl.classList.toggle('collapsed');
|
||||
e.target.textContent = collapsed ? '펼치기' : '접기';
|
||||
});
|
||||
|
||||
const VIEWS = {
|
||||
front: { theta: Math.PI / 2, phi: Math.PI / 2 },
|
||||
@@ -277,18 +296,37 @@
|
||||
});
|
||||
document.getElementById('btnClear').addEventListener('click', clearMeasure);
|
||||
|
||||
let mode = null, lastX = 0, lastY = 0, downX = 0, downY = 0;
|
||||
let mode = null, lastX = 0, lastY = 0, downX = 0, downY = 0, pinchDist = 0;
|
||||
const pointers = new Map();
|
||||
const pinchSpan = () => {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
};
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
mode = e.button === 2 ? 'pan' : 'rotate';
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
downX = e.clientX; downY = e.clientY;
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 1) {
|
||||
mode = e.button === 2 ? 'pan' : 'rotate';
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
downX = e.clientX; downY = e.clientY;
|
||||
} else if (pointers.size === 2) {
|
||||
mode = 'pinch';
|
||||
pinchDist = pinchSpan();
|
||||
zoomPivot = target.clone();
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (measuring && mode === 'rotate' && Math.hypot(e.clientX - downX, e.clientY - downY) < 4) pickPoint(e);
|
||||
mode = null;
|
||||
});
|
||||
window.addEventListener('mousemove', e => {
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (mode === 'pinch') {
|
||||
const d = pinchSpan();
|
||||
if (d > 0 && pinchDist > 0) {
|
||||
zoomTarget = Math.min(1e6, Math.max(0.05, (zoomTarget ?? radius) * (pinchDist / d)));
|
||||
}
|
||||
pinchDist = d;
|
||||
return;
|
||||
}
|
||||
if (!mode) return;
|
||||
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
@@ -304,6 +342,20 @@
|
||||
}
|
||||
updateCamera();
|
||||
});
|
||||
const endPointer = e => {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
if (measuring && mode === 'rotate' && pointers.size === 1 && Math.hypot(e.clientX - downX, e.clientY - downY) < 6) pickPoint(e);
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size === 0) {
|
||||
mode = null;
|
||||
} else if (pointers.size === 1) {
|
||||
mode = 'rotate';
|
||||
const p = [...pointers.values()][0];
|
||||
lastX = p.x; lastY = p.y;
|
||||
}
|
||||
};
|
||||
canvas.addEventListener('pointerup', endPointer);
|
||||
canvas.addEventListener('pointercancel', endPointer);
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const ground = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
||||
|
||||
Reference in new issue
Block a user