feat(viewer): revise web viewer UI/UX to Prism design system (webapp module)
- Apply webapp/module.webapp.md: CSS variable block (verbatim), W1 app shell (header with meta slots, left nav rail >=904px, stacked below), 38em measure. - Colours and type from tokens only; part colours mapped to dv tokens. - Controls per catalog: one primary button, secondary/quiet variants, verb-first labels <=8 chars, 44px hit areas, 3px focus ring with 2px offset. - Theme: system / light / dark, choice persisted (try/catch); prefers-reduced-motion. - Part attributes shown as a specsheet in the tooltip and in the rail; keyboard part picker added for non-pointer users. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5Cun3RHQKna9qxE6ybZ4A
This commit is contained in:
1 parent
5e566cdb75
commit
e254718309
1 file changed
+475
-127
+475
-127
@@ -2,75 +2,324 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>대산2터널 본선 3D 모델</title>
|
||||
<style>
|
||||
:root{--bg:#f6f6f4;--fg:#1d1d1b;--panel:rgba(255,255,255,.95);--line:#d7d7d2;--accent:#2f6f6a;--muted:#6b6b66}
|
||||
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.95);--line:#3a3f46;--accent:#6fb7b0;--muted:#9aa0a6}}
|
||||
:root[data-theme="dark"]{--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.95);--line:#3a3f46;--accent:#6fb7b0;--muted:#9aa0a6}
|
||||
html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font-family:system-ui,"Noto Sans KR",sans-serif;overflow:hidden}
|
||||
#app{position:fixed;inset:0}
|
||||
#panel{position:absolute;left:12px;top:12px;width:300px;max-width:calc(100vw - 24px);max-height:calc(100vh - 24px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;box-sizing:border-box}
|
||||
#panel h1{font-size:15px;margin:0 0 4px;color:var(--accent)}
|
||||
#panel h2{font-size:12px;margin:12px 0 5px;color:var(--accent)}
|
||||
#panel label{display:flex;gap:6px;align-items:center;margin:3px 0;cursor:pointer}
|
||||
button{font:inherit;font-size:12px;padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:transparent;color:var(--fg);cursor:pointer}
|
||||
button:hover{border-color:var(--accent)}
|
||||
.row{display:flex;gap:6px;flex-wrap:wrap;margin:4px 0}
|
||||
#pats{display:grid;grid-template-columns:1fr 1fr;gap:0 10px}
|
||||
.dual{position:relative;height:26px;margin:4px 0}
|
||||
.dual input[type=range]{position:absolute;left:0;top:4px;width:100%;margin:0;pointer-events:none;-webkit-appearance:none;appearance:none;background:transparent;height:18px}
|
||||
.dual input[type=range]::-webkit-slider-thumb{pointer-events:auto;-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer}
|
||||
.dual input[type=range]::-moz-range-thumb{pointer-events:auto;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;border:0}
|
||||
.dual .track{position:absolute;left:7px;right:7px;top:11px;height:4px;background:var(--line);border-radius:2px}
|
||||
#staText{font-weight:600;font-size:13px;margin-top:2px}
|
||||
#info{font-size:12px;color:var(--muted);margin-top:10px;border-top:1px solid var(--line);padding-top:8px;min-height:2.6em}
|
||||
#tip{position:fixed;display:none;max-width:320px;background:var(--panel);border:1px solid var(--accent);border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.55;white-space:pre-wrap;pointer-events:none;z-index:5;box-shadow:0 2px 10px rgba(0,0,0,.2)}
|
||||
#tip b{color:var(--accent)}
|
||||
#status{position:absolute;right:12px;bottom:12px;font-size:12px;background:var(--panel);padding:4px 8px;border-radius:6px;border:1px solid var(--line)}
|
||||
/* ── Prism CSS 변수 정본 (webapp/module.webapp.md §3 탑재, 수정 금지) ── */
|
||||
:root {
|
||||
/* ── color (light) ─────────────────────────────── */
|
||||
--prism-bg-canvas: #F4F3F0; /* sand G4 — 순백 전면 금지 */
|
||||
--prism-bg-card: #FFFFFF; /* white G0 */
|
||||
--prism-text-body: #474235; /* brown G72 — on sand 9.02 AAA */
|
||||
--prism-text-heading: #1F524B; /* green G69 — on sand 8.00 AAA */
|
||||
--prism-text-subtle: #474235; /* 저강조는 색이 아니라 크기·웨이트로 */
|
||||
--prism-text-accent: #D05C26; /* orange G47 — 대형 전용(24px+/19px+Bold) */
|
||||
--prism-border-default: #474235;
|
||||
--prism-border-strong: #1F524B;
|
||||
--prism-status-do: #005A4E; --prism-status-do-bg: #DCEAE6;
|
||||
--prism-status-dont: #8F2A11; --prism-status-dont-bg: #F6DFDA;
|
||||
--prism-status-caution: #6B4A00; --prism-status-caution-bg: #F5E7CC;
|
||||
--prism-status-tip: #0A4E86; --prism-status-tip-bg: #DDE8F3;
|
||||
--prism-dv-1: #C08A2E; --prism-dv-2: #6E8F3C; --prism-dv-3: #3C6E9F;
|
||||
--prism-dv-4: #8C3B14; --prism-dv-5: #1B4A43; --prism-dv-6: #5E4370;
|
||||
|
||||
/* ── space (base 8px = 0.5rem) ──────────────────── */
|
||||
--prism-space-025: 0.125rem; /* 2px */
|
||||
--prism-space-050: 0.25rem; /* 4px */
|
||||
--prism-space-100: 0.5rem; /* 8px */
|
||||
--prism-space-150: 0.75rem; /* 12px */
|
||||
--prism-space-200: 1rem; /* 16px — card padding */
|
||||
--prism-space-300: 1.5rem; /* 24px — block gap */
|
||||
--prism-space-400: 2rem; /* 32px — zone gap */
|
||||
--prism-space-600: 3rem; /* 48px */
|
||||
--prism-space-800: 4rem; /* 64px */
|
||||
|
||||
/* ── type (root 16px 미고정 — html font-size 선언 금지) ── */
|
||||
--prism-type-caption: 0.8125rem; --prism-lh-caption: 1.55;
|
||||
--prism-type-body-sm: 0.875rem; --prism-lh-body: 1.70;
|
||||
--prism-type-body: 1rem;
|
||||
--prism-type-body-lg: 1.125rem;
|
||||
--prism-type-title-sm: 1.25rem; --prism-lh-title: 1.50;
|
||||
--prism-type-title: 1.625rem;
|
||||
--prism-type-title-lg: 2rem;
|
||||
--prism-type-display: 2.875rem; --prism-lh-display: 1.25;
|
||||
--prism-type-display-lg: 4.125rem;
|
||||
|
||||
/* ── radius · stroke · z · motion ───────────────── */
|
||||
--prism-radius-sm: 4px; --prism-radius-md: 8px; --prism-radius-lg: 12px;
|
||||
--prism-radius-pill: 9999px;
|
||||
--prism-stroke-hairline: 1px; --prism-stroke-regular: 2px; --prism-stroke-bold: 3px;
|
||||
--prism-z-raised: 100; --prism-z-sticky: 200; --prism-z-dropdown: 300;
|
||||
--prism-z-scrim: 400; --prism-z-modal: 500; --prism-z-popover: 600;
|
||||
--prism-z-toast: 700; --prism-z-tooltip: 800;
|
||||
--prism-scrim: rgba(0, 0, 0, 0.32);
|
||||
--prism-motion-fast: 100ms; --prism-motion-moderate: 200ms; --prism-motion-slow: 300ms;
|
||||
--prism-ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--prism-ease-enter: cubic-bezier(0, 0, 0, 1);
|
||||
--prism-ease-exit: cubic-bezier(0.3, 0, 1, 1);
|
||||
--prism-focus-ring: 3px; --prism-focus-offset: 2px;
|
||||
--prism-measure-max: 38em;
|
||||
}
|
||||
|
||||
/* ── dark theme (data-theme + OS 연동) ─────────────── */
|
||||
:root[data-theme="dark"] {
|
||||
--prism-bg-canvas: #111111; /* obsidian G95 */
|
||||
--prism-bg-card: #111111; /* 카드는 보더로 분리 */
|
||||
--prism-text-body: #F4F3F0; /* sand on obsidian 17.02 AAA */
|
||||
--prism-text-heading: #F4F3F0; /* ⚠ green 텍스트 금지 (2.13:1) — 면·도형만 */
|
||||
--prism-text-subtle: #D3D3D3; /* subtle 12.61 AAA */
|
||||
--prism-text-accent: #D05C26; /* 4.71 AA — 대형 권장 */
|
||||
--prism-border-default: #D3D3D3;
|
||||
--prism-border-strong: #F4F3F0;
|
||||
/* 상태색 다크: container↔on 반전 (tokens v0.1.1, 실측 동일) */
|
||||
--prism-status-do: #DCEAE6; --prism-status-do-bg: #005A4E;
|
||||
--prism-status-dont: #F6DFDA; --prism-status-dont-bg: #8F2A11;
|
||||
--prism-status-caution: #F5E7CC; --prism-status-caution-bg: #6B4A00;
|
||||
--prism-status-tip: #DDE8F3; --prism-status-tip-bg: #0A4E86;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) { /* 위 dark 값셋 동일 적용 */
|
||||
--prism-bg-canvas: #111111; --prism-bg-card: #111111;
|
||||
--prism-text-body: #F4F3F0; --prism-text-heading: #F4F3F0;
|
||||
--prism-text-subtle: #D3D3D3; --prism-text-accent: #D05C26;
|
||||
--prism-border-default: #D3D3D3; --prism-border-strong: #F4F3F0;
|
||||
--prism-status-do: #DCEAE6; --prism-status-do-bg: #005A4E;
|
||||
--prism-status-dont: #F6DFDA; --prism-status-dont-bg: #8F2A11;
|
||||
--prism-status-caution: #F5E7CC; --prism-status-caution-bg: #6B4A00;
|
||||
--prism-status-tip: #DDE8F3; --prism-status-tip-bg: #0A4E86;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 베이스 (CSS-1·CSS-2: root font-size 미지정, 타입·간격 rem) ── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
background: var(--prism-bg-canvas); color: var(--prism-text-body);
|
||||
font-family: Pretendard, -apple-system, BlinkMacSystemFont, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||
font-size: var(--prism-type-body); line-height: var(--prism-lh-body);
|
||||
}
|
||||
h1, h2, p, dl, dd, dt { margin: 0; }
|
||||
h1 { font-size: var(--prism-type-title-sm); line-height: var(--prism-lh-title); font-weight: 700; color: var(--prism-text-heading); }
|
||||
h2 { font-size: var(--prism-type-body-lg); line-height: var(--prism-lh-title); font-weight: 700; color: var(--prism-text-heading); margin-block: var(--prism-space-300) var(--prism-space-100); }
|
||||
.rail h2:first-child { margin-block-start: 0; }
|
||||
|
||||
/* 포커스: 3px 링 + 오프셋 2px, :focus-visible 전용 (FC-1·FC-2) */
|
||||
:focus-visible { outline: var(--prism-focus-ring) solid var(--prism-text-heading); outline-offset: var(--prism-focus-offset); }
|
||||
|
||||
.skip-link {
|
||||
position: absolute; inset-inline-start: var(--prism-space-200); inset-block-start: var(--prism-space-100);
|
||||
transform: translateY(-200%); z-index: var(--prism-z-toast);
|
||||
padding: var(--prism-space-100) var(--prism-space-200); border-radius: var(--prism-radius-md);
|
||||
background: var(--prism-bg-card); color: var(--prism-text-heading); border: var(--prism-stroke-hairline) solid var(--prism-border-strong);
|
||||
}
|
||||
.skip-link:focus { transform: none; }
|
||||
|
||||
/* 헤더 — W1 셸 메타 슬롯 */
|
||||
.hdr {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: var(--prism-space-100) var(--prism-space-400);
|
||||
padding: var(--prism-space-150) var(--prism-space-300);
|
||||
background: var(--prism-bg-card); border-bottom: var(--prism-stroke-hairline) solid var(--prism-border-default);
|
||||
}
|
||||
.meta {
|
||||
display: flex; flex-wrap: wrap; gap: var(--prism-space-050) var(--prism-space-300);
|
||||
margin-inline-start: auto; font-size: var(--prism-type-caption); line-height: var(--prism-lh-caption);
|
||||
}
|
||||
.meta div { display: flex; gap: var(--prism-space-050); }
|
||||
.meta dt { color: var(--prism-text-subtle); }
|
||||
.meta dd { color: var(--prism-text-body); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* 테마 3택 (TC-2) */
|
||||
.seg { display: inline-flex; overflow: hidden; border-radius: var(--prism-radius-md); border: var(--prism-stroke-hairline) solid var(--prism-border-default); }
|
||||
.seg button { border: 0; border-radius: 0; background: transparent; }
|
||||
.seg button + button { border-inline-start: var(--prism-stroke-hairline) solid var(--prism-border-default); }
|
||||
.seg button[aria-pressed="true"] { background: var(--prism-text-heading); color: var(--prism-bg-card); }
|
||||
|
||||
/* 셸: 좌측 내비 레일 + 뷰포트 (W1) */
|
||||
.shell { flex: 1; min-height: 0; display: grid; grid-template-columns: 17.5rem minmax(0, 1fr); }
|
||||
.rail {
|
||||
overflow: auto; padding: var(--prism-space-200);
|
||||
background: var(--prism-bg-card); border-inline-end: var(--prism-stroke-hairline) solid var(--prism-border-default);
|
||||
}
|
||||
.stage { position: relative; min-width: 0; min-height: 0; }
|
||||
#viewport { position: absolute; inset: 0; }
|
||||
#viewport canvas { display: block; }
|
||||
|
||||
/* 버튼 — primary 화면당 1개 (BT-1), 히트 영역 44px (HT-1) */
|
||||
button {
|
||||
font: inherit; font-size: var(--prism-type-body); font-weight: 500; line-height: 1.2;
|
||||
min-height: 2.75rem; padding: var(--prism-space-050) var(--prism-space-100); white-space: nowrap;
|
||||
border: var(--prism-stroke-hairline) solid var(--prism-border-default); border-radius: var(--prism-radius-md);
|
||||
background: var(--prism-bg-card); color: var(--prism-text-body); cursor: pointer;
|
||||
}
|
||||
button.primary { background: var(--prism-text-heading); border-color: var(--prism-text-heading); color: var(--prism-bg-card); }
|
||||
button.quiet { background: transparent; border-color: transparent; color: var(--prism-text-heading); }
|
||||
.btn-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--prism-space-100); }
|
||||
.btn-grid .wide { grid-column: 1 / -1; }
|
||||
|
||||
/* 체크박스 행 — 히트 영역 44px */
|
||||
.opt {
|
||||
display: flex; align-items: center; gap: var(--prism-space-100);
|
||||
min-height: 2.75rem; cursor: pointer; color: var(--prism-text-body);
|
||||
}
|
||||
.opt input[type="checkbox"] { width: 1.25rem; height: 1.25rem; margin: 0; flex: none; accent-color: var(--prism-text-heading); }
|
||||
.pats { display: grid; grid-template-columns: 1fr 1fr; column-gap: var(--prism-space-100); }
|
||||
.sw {
|
||||
width: var(--prism-space-200); height: var(--prism-space-200); flex: none;
|
||||
border-radius: var(--prism-radius-sm); border: var(--prism-stroke-hairline) solid var(--prism-border-default);
|
||||
}
|
||||
.sw.ghost { opacity: 0.5; }
|
||||
|
||||
/* 구간 슬라이더 — 두 핸들, 히트 영역 44px */
|
||||
.dual { position: relative; height: 2.75rem; margin-block: var(--prism-space-050); }
|
||||
.dual .track {
|
||||
position: absolute; inset-inline: var(--prism-space-150); top: 50%; height: var(--prism-stroke-regular);
|
||||
transform: translateY(-50%); border-radius: var(--prism-radius-sm); background: var(--prism-border-default);
|
||||
}
|
||||
.dual input[type="range"] {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%; margin: 0;
|
||||
background: transparent; pointer-events: none; appearance: none; -webkit-appearance: none;
|
||||
}
|
||||
.dual input[type="range"]::-webkit-slider-runnable-track { background: transparent; height: 100%; }
|
||||
.dual input[type="range"]::-moz-range-track { background: transparent; }
|
||||
.dual input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none; pointer-events: auto; cursor: pointer;
|
||||
width: 1.25rem; height: 1.25rem; margin-block-start: var(--prism-space-150); border-radius: 50%;
|
||||
background: var(--prism-text-heading); border: var(--prism-stroke-regular) solid var(--prism-bg-card);
|
||||
}
|
||||
.dual input[type="range"]::-moz-range-thumb {
|
||||
pointer-events: auto; cursor: pointer; width: 1.25rem; height: 1.25rem; border-radius: 50%;
|
||||
background: var(--prism-text-heading); border: var(--prism-stroke-regular) solid var(--prism-bg-card);
|
||||
}
|
||||
.sta { font-weight: 700; font-variant-numeric: tabular-nums; margin-block: var(--prism-space-050) var(--prism-space-100); }
|
||||
|
||||
/* 선택 목록 */
|
||||
select {
|
||||
font: inherit; font-size: var(--prism-type-body); width: 100%; min-height: 2.75rem;
|
||||
padding: var(--prism-space-050) var(--prism-space-100); border-radius: var(--prism-radius-md);
|
||||
border: var(--prism-stroke-hairline) solid var(--prism-border-default);
|
||||
background: var(--prism-bg-card); color: var(--prism-text-body);
|
||||
}
|
||||
.label { display: block; margin-block-end: var(--prism-space-050); color: var(--prism-text-subtle); font-size: var(--prism-type-caption); line-height: var(--prism-lh-caption); }
|
||||
.note { color: var(--prism-text-subtle); font-size: var(--prism-type-caption); line-height: var(--prism-lh-caption); max-inline-size: var(--prism-measure-max); margin-block-start: var(--prism-space-100); }
|
||||
.empty { color: var(--prism-text-subtle); font-size: var(--prism-type-body-sm); margin-block-start: var(--prism-space-100); }
|
||||
|
||||
/* 명세표 (specsheet) — 항목 말미 고정 위치, 열 구성 동일 (SS-1·SS-2) */
|
||||
.spec {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--prism-space-050) var(--prism-space-150);
|
||||
font-size: var(--prism-type-body-sm); line-height: var(--prism-lh-body); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.spec dt { color: var(--prism-text-subtle); }
|
||||
.spec dd { overflow-wrap: anywhere; color: var(--prism-text-body); }
|
||||
|
||||
/* 툴팁 — overlay 단계 (z.tooltip 800, 보더 우선) */
|
||||
#tip {
|
||||
position: fixed; display: none; pointer-events: none; z-index: var(--prism-z-tooltip);
|
||||
max-inline-size: 20rem; padding: var(--prism-space-150) var(--prism-space-200);
|
||||
background: var(--prism-bg-card); color: var(--prism-text-body);
|
||||
border: var(--prism-stroke-hairline) solid var(--prism-border-strong); border-radius: var(--prism-radius-md);
|
||||
box-shadow: 0 var(--prism-space-050) var(--prism-space-150) var(--prism-scrim);
|
||||
}
|
||||
#tip .tip-title { font-weight: 700; color: var(--prism-text-heading); margin-block-end: var(--prism-space-100); }
|
||||
|
||||
/* 상호작용 — hover는 보강 수단, hover 기기에서만 (HV-3·HV-4) */
|
||||
@media (hover: hover) {
|
||||
button:not(.primary):hover { border-color: var(--prism-border-strong); }
|
||||
button.primary:hover { background: color-mix(in srgb, var(--prism-text-heading) 88%, var(--prism-text-body)); border-color: transparent; }
|
||||
button.quiet:hover { background: var(--prism-bg-canvas); }
|
||||
.opt:hover { color: var(--prism-text-heading); }
|
||||
}
|
||||
|
||||
/* 좁은 화면: 레일을 본문 아래로 (BP md 904 미만) */
|
||||
@media (max-width: 903px) {
|
||||
body { height: auto; min-height: 100%; overflow: auto; }
|
||||
.hdr { padding: var(--prism-space-150) var(--prism-space-200); }
|
||||
.meta { margin-inline-start: 0; }
|
||||
.shell { display: flex; flex-direction: column; flex: none; }
|
||||
.stage { order: -1; flex: none; height: 60vh; min-height: 20rem; }
|
||||
.rail { overflow: visible; border-inline-end: 0; border-block-start: var(--prism-stroke-hairline) solid var(--prism-border-default); }
|
||||
}
|
||||
|
||||
/* 모션: 축소 요청 시 전환 제거 (MT-3·RM-1) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="panel">
|
||||
<a class="skip-link" href="#main">본문 바로가기</a>
|
||||
|
||||
<header class="hdr">
|
||||
<h1>대산2터널 본선 3D 모델</h1>
|
||||
<div style="color:var(--muted)">측점 4+745 ~ 5+480 · 단위 m</div>
|
||||
<div class="row"><button id="btnStart" type="button">종단 시점</button><button id="btnAll" type="button">전체 맞춤</button></div>
|
||||
|
||||
<h2>구간 보기 (시작 · 끝 측점)</h2>
|
||||
<div class="dual">
|
||||
<div class="track"></div>
|
||||
<input type="range" id="s0" min="4745" max="5480" step="5" value="4745">
|
||||
<input type="range" id="s1" min="4745" max="5480" step="5" value="5480">
|
||||
<dl class="meta">
|
||||
<div><dt>측점</dt><dd>4+745 ~ 5+480</dd></div>
|
||||
<div><dt>부품</dt><dd id="metaCount">—</dd></div>
|
||||
<div><dt>단위</dt><dd>m</dd></div>
|
||||
<div><dt>상태</dt><dd id="status" role="status">불러오는 중</dd></div>
|
||||
</dl>
|
||||
<div class="seg" role="group" aria-label="화면 테마">
|
||||
<button type="button" data-theme-choice="system" aria-pressed="true">시스템</button>
|
||||
<button type="button" data-theme-choice="light" aria-pressed="false">라이트</button>
|
||||
<button type="button" data-theme-choice="dark" aria-pressed="false">다크</button>
|
||||
</div>
|
||||
<div id="staText">4+745 ~ 5+480</div>
|
||||
<div class="row"><button id="btnWin" type="button">구간 맞춤</button><button id="btnWinReset" type="button">구간 전체</button></div>
|
||||
</header>
|
||||
|
||||
<h2>지보패턴 (라이닝·굴착 여유대)</h2>
|
||||
<div id="pats"></div>
|
||||
<h2>굴착 여유대</h2>
|
||||
<label><input type="checkbox" id="showExc" checked> 굴착 여유대 표시</label>
|
||||
<h2>구성요소</h2>
|
||||
<div id="comps"></div>
|
||||
<div class="shell">
|
||||
<nav class="rail" aria-label="모델 제어">
|
||||
<h2>시점</h2>
|
||||
<div class="btn-grid">
|
||||
<button type="button" class="primary" id="btnStart">종단 보기</button>
|
||||
<button type="button" id="btnAll">전체 맞추기</button>
|
||||
</div>
|
||||
|
||||
<h2>선택 부품</h2>
|
||||
<div class="row">
|
||||
<button id="btnFitSel" type="button">선택 맞춤 (zoom fit)</button>
|
||||
<button id="btnDesel" type="button">선택 해제 (Esc)</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="btnHideSel" type="button">선택 숨기기</button>
|
||||
<button id="btnIsoSel" type="button">선택만 보기</button>
|
||||
<button id="btnShowAll" type="button">모두 보기</button>
|
||||
</div>
|
||||
<label><input type="checkbox" id="wire"> 와이어프레임</label>
|
||||
<div id="info">부품을 클릭하면 강조되고 속성이 표시됩니다.</div>
|
||||
<h2>구간</h2>
|
||||
<div class="dual">
|
||||
<div class="track"></div>
|
||||
<input type="range" id="s0" min="4745" max="5480" step="5" value="4745" aria-label="시작 측점">
|
||||
<input type="range" id="s1" min="4745" max="5480" step="5" value="5480" aria-label="끝 측점">
|
||||
</div>
|
||||
<p class="sta" id="staText" aria-live="polite">4+745 ~ 5+480</p>
|
||||
<div class="btn-grid">
|
||||
<button type="button" id="btnWin">구간 맞추기</button>
|
||||
<button type="button" id="btnWinReset" class="quiet">구간 전체</button>
|
||||
</div>
|
||||
<p class="note">슬라이더는 5 m 단위로 움직입니다.</p>
|
||||
|
||||
<h2>지보패턴</h2>
|
||||
<div class="pats" id="pats"></div>
|
||||
<p class="note">라이닝과 굴착 여유대를 패턴별로 켜고 끕니다.</p>
|
||||
|
||||
<h2>구성요소</h2>
|
||||
<div id="comps"></div>
|
||||
|
||||
<h2>부품 선택</h2>
|
||||
<label class="label" for="pickSel">목록에서 부품 고르기</label>
|
||||
<select id="pickSel">
|
||||
<option value="">선택 없음</option>
|
||||
</select>
|
||||
<dl class="spec" id="detailSpec" hidden style="margin-block-start:var(--prism-space-150)"></dl>
|
||||
<p class="empty" id="detailEmpty">부품을 클릭하거나 목록에서 고르면 속성이 표시됩니다.</p>
|
||||
<div class="btn-grid" style="margin-block-start:var(--prism-space-150)">
|
||||
<button type="button" id="btnFitSel">선택 맞추기</button>
|
||||
<button type="button" id="btnDesel" class="quiet">선택 해제</button>
|
||||
<button type="button" id="btnHideSel">선택 숨기기</button>
|
||||
<button type="button" id="btnIsoSel">선택만 보기</button>
|
||||
<button type="button" id="btnShowAll" class="quiet wide">모두 보기</button>
|
||||
</div>
|
||||
<label class="opt" style="margin-block-start:var(--prism-space-150)"><input type="checkbox" id="wire"> 와이어프레임으로 보기</label>
|
||||
<p class="note">드래그 회전 · 휠 확대 · 클릭 선택 · Esc 선택 해제</p>
|
||||
</nav>
|
||||
|
||||
<main id="main" class="stage">
|
||||
<div id="viewport" role="img" aria-label="대산2터널 본선 3D 모델, 측점 4+745부터 5+480까지. 드래그로 회전하고 클릭하면 부품이 선택됩니다. 키보드는 부품 선택 목록을 사용합니다."></div>
|
||||
<div id="tip" aria-hidden="true"></div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="tip"></div>
|
||||
<div id="status">불러오는 중…</div>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
@@ -79,24 +328,22 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
const MODEL = '../model/tunnel_solids.glb';
|
||||
const META = '../model/tunnel_solids_meta.json';
|
||||
const S_MIN = 4745, S_MAX = 5480;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const statusEl = $('status'), infoEl = $('info'), tipEl = $('tip');
|
||||
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const THEME_KEY = 'prism-viewer-theme';
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
$('app').appendChild(renderer.domElement);
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(dark ? 0x15171a : 0xf6f6f4);
|
||||
const camera = new THREE.PerspectiveCamera(30, innerWidth / innerHeight, 0.5, 200000);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x556070, 1.0));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.9); sun.position.set(1, 2, 1); scene.add(sun);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, 0.6); fill.position.set(-1, -0.5, -1.2); scene.add(fill);
|
||||
|
||||
const fmtSta = (m) => Math.floor(m / 1000) + '+' + String(Math.round(m % 1000)).padStart(3, '0');
|
||||
// 부품 그룹 → 디자인 토큰 (색은 모두 토큰에서 읽는다, 리터럴 없음)
|
||||
const PART = {
|
||||
'라이닝': { token: '--prism-dv-1', order: 0 },
|
||||
'굴착면': { token: '--prism-text-subtle', order: 1, ghost: true },
|
||||
'노면(계획고)': { token: '--prism-dv-4', order: 2 },
|
||||
'기층·인버트(가정)': { token: '--prism-dv-2', order: 3 },
|
||||
'배수(측구)': { token: '--prism-dv-3', order: 4 },
|
||||
'공동구(가정)': { token: '--prism-dv-6', order: 5 },
|
||||
'시설한계(참조선)': { token: '--prism-text-accent', order: 6 }
|
||||
};
|
||||
const TOGGLES = [
|
||||
['굴착면', '굴착 여유대'], ['노면(계획고)', '노면 (계획고)'], ['기층·인버트(가정)', '기층·인버트 (가정)'],
|
||||
['배수(측구)', '배수 (측구)'], ['공동구(가정)', '공동구 (가정)'], ['시설한계(참조선)', '시설한계 50 mm 참조선']
|
||||
];
|
||||
const NOTE = {
|
||||
'노면(계획고)': '노면 포장 280 mm · 측점별 편경사 적용 (도면 계획고 기준)',
|
||||
'기층·인버트(가정)': '가정 형상 · 도면에 표현되지 않아 가정하여 생성',
|
||||
@@ -107,6 +354,52 @@ const NOTE = {
|
||||
'라이닝': '라이닝 R 6.982 / 7.282 m (지보패턴별 구간)'
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const fmtSta = (m) => Math.floor(m / 1000) + '+' + String(Math.round(m % 1000)).padStart(3, '0');
|
||||
const tok = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const statusEl = $('status'), tipEl = $('tip');
|
||||
|
||||
// ── 테마 (TC-1~TC-4): 시스템 기본, 사용자 재정의 영속 ──
|
||||
let scene = null, renderer = null;
|
||||
let currentChoice = 'system';
|
||||
try { currentChoice = localStorage.getItem(THEME_KEY) || 'system'; } catch (e) { /* 저장소 불가 시 시스템 값 */ }
|
||||
function setThemeAttr(choice) {
|
||||
const root = document.documentElement;
|
||||
if (choice === 'light' || choice === 'dark') root.setAttribute('data-theme', choice);
|
||||
else root.removeAttribute('data-theme');
|
||||
document.querySelectorAll('[data-theme-choice]').forEach((b) =>
|
||||
b.setAttribute('aria-pressed', String(b.dataset.themeChoice === choice)));
|
||||
}
|
||||
function applyTheme(choice, persist = true) {
|
||||
currentChoice = choice;
|
||||
setThemeAttr(choice);
|
||||
if (persist) { try { localStorage.setItem(THEME_KEY, choice); } catch (e) { /* 무시 */ } }
|
||||
if (scene) refreshColors();
|
||||
}
|
||||
setThemeAttr(currentChoice);
|
||||
document.querySelectorAll('[data-theme-choice]').forEach((b) =>
|
||||
b.addEventListener('click', () => applyTheme(b.dataset.themeChoice)));
|
||||
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (scene) refreshColors(); });
|
||||
|
||||
// ── 3D 장면 ──
|
||||
const vp = $('viewport');
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(vp.clientWidth, vp.clientHeight);
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true');
|
||||
vp.appendChild(renderer.domElement);
|
||||
scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(30, vp.clientWidth / Math.max(vp.clientHeight, 1), 0.5, 200000);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
scene.add(new THREE.HemisphereLight(new THREE.Color(1, 1, 1), new THREE.Color(0.333, 0.376, 0.439), 1.0)); // 조명(장면 광원, UI 색 아님)
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.9); sun.position.set(1, 2, 1); scene.add(sun);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, 0.6); fill.position.set(-1, -0.5, -1.2); scene.add(fill);
|
||||
new ResizeObserver(() => {
|
||||
const w = vp.clientWidth, h = Math.max(vp.clientHeight, 1);
|
||||
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
|
||||
}).observe(vp);
|
||||
|
||||
const meshes = [];
|
||||
let META_BY_ID = {};
|
||||
const patternOn = {}, groupOn = {};
|
||||
@@ -115,14 +408,22 @@ let selected = null, isolate = false;
|
||||
const hiddenSel = new Set();
|
||||
let world = null;
|
||||
|
||||
function refreshColors() {
|
||||
scene.background = new THREE.Color(tok('--prism-bg-canvas'));
|
||||
for (const m of meshes) {
|
||||
const p = PART[m.userData.group];
|
||||
m.material.color.set(tok(p ? p.token : '--prism-text-subtle'));
|
||||
}
|
||||
if (selected) selected.material.emissive.set(tok('--prism-text-heading'));
|
||||
}
|
||||
|
||||
function visibleOf(m) {
|
||||
const u = m.userData;
|
||||
if (hiddenSel.has(m)) return false;
|
||||
if (isolate && selected && m !== selected) return false;
|
||||
if (u.from != null && (u.to < winLo || u.from > winHi)) return false;
|
||||
if (u.group === '시설한계(참조선)' && (winLo > S_MIN || winHi < S_MAX)) return false; // full-length reference lines: full range only
|
||||
if (u.group === '시설한계(참조선)' && (winLo > S_MIN || winHi < S_MAX)) return false; // 전체 길이 참조선: 전체 구간에서만
|
||||
if (u.pattern) { if (!patternOn[u.pattern]) return false; }
|
||||
if (u.group === '굴착면' && !$('showExc').checked) return false;
|
||||
if (u.group in groupOn && !groupOn[u.group]) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -148,7 +449,7 @@ function frameBox(b, pad) {
|
||||
function frameAll() { applyVisibility(); const b = boxOf(meshes.filter((m) => m.visible)); if (b) frameBox(b, 0.75); }
|
||||
function frameWindow() { applyVisibility(); const b = boxOf(meshes.filter((m) => m.visible)); if (b) frameBox(b, 0.9); }
|
||||
|
||||
// longitudinal (종단) start view: chainage runs left -> right, height up
|
||||
// 종단 시점: 측점이 좌→우로 나열, 높이는 위
|
||||
function startView() {
|
||||
const first = meshes.find((m) => m.userData.group === '라이닝' && m.userData.from === S_MIN);
|
||||
const last = meshes.find((m) => m.userData.group === '라이닝' && m.userData.to === S_MAX);
|
||||
@@ -158,8 +459,7 @@ function startView() {
|
||||
const u = new THREE.Vector3(c1.x - c0.x, 0, c1.z - c0.z);
|
||||
const L = u.length(); u.normalize();
|
||||
const mid = c0.clone().add(c1).multiplyScalar(0.5);
|
||||
// camera looks along lateral w; chainage (u) must point to screen-right: right = w × up = u
|
||||
const w = new THREE.Vector3(u.z, 0, -u.x);
|
||||
const w = new THREE.Vector3(u.z, 0, -u.x); // 카메라 시선 방향 (측점 u가 화면 우측)
|
||||
controls.target.copy(mid);
|
||||
camera.up.set(0, 1, 0);
|
||||
camera.position.copy(mid).addScaledVector(w, -L * 2.1).add(new THREE.Vector3(0, L * 0.02, 0));
|
||||
@@ -167,22 +467,56 @@ function startView() {
|
||||
controls.update();
|
||||
}
|
||||
|
||||
// ── 부품 상세 (명세표) ──
|
||||
function specRows(u) {
|
||||
return [
|
||||
['구분', u.group || '—'],
|
||||
['지보패턴', u.pattern || '—'],
|
||||
['측점', u.from != null ? fmtSta(u.from) + ' ~ ' + fmtSta(u.to) + ' (' + Math.round(u.to - u.from) + ' m)' : '—'],
|
||||
['부피', u.volume != null ? Number(u.volume).toFixed(2) + ' m³' : '—'],
|
||||
['비고', NOTE[u.group] || '—'],
|
||||
['부품 ID', u.id || '—']
|
||||
];
|
||||
}
|
||||
function fillSpec(dl, rows) {
|
||||
dl.replaceChildren();
|
||||
for (const [k, v] of rows) {
|
||||
const dt = document.createElement('dt'); dt.textContent = k;
|
||||
const dd = document.createElement('dd'); dd.textContent = v;
|
||||
dl.append(dt, dd);
|
||||
}
|
||||
}
|
||||
function renderDetail(u) {
|
||||
const dl = $('detailSpec'), empty = $('detailEmpty');
|
||||
if (!u) { dl.hidden = true; empty.hidden = false; dl.replaceChildren(); return; }
|
||||
fillSpec(dl, specRows(u)); dl.hidden = false; empty.hidden = true;
|
||||
}
|
||||
|
||||
function setSelected(m) {
|
||||
if (selected && selected.material) selected.material.emissive.setHex(0x000000);
|
||||
if (selected) selected.material.emissive.setRGB(0, 0, 0);
|
||||
selected = m;
|
||||
if (m) m.material.emissive.setHex(0xb8a000); // highlight: strong yellow emissive
|
||||
if (m) m.material.emissive.set(tok('--prism-text-heading'));
|
||||
$('pickSel').value = m ? String(m.userData.idx) : '';
|
||||
applyVisibility();
|
||||
if (!m) { infoEl.textContent = '부품을 클릭하면 강조되고 속성이 표시됩니다.'; tipEl.style.display = 'none'; return; }
|
||||
infoEl.textContent = '선택: ' + m.userData.group + (m.userData.pattern ? ' · 지보패턴 ' + m.userData.pattern : '');
|
||||
renderDetail(m ? m.userData : null);
|
||||
}
|
||||
function tipText(u) {
|
||||
const lines = ['<b>' + u.group + '</b>' + (u.pattern ? ' · 지보패턴 ' + u.pattern : '')];
|
||||
if (u.from != null) lines.push('측점 ' + fmtSta(u.from) + ' ~ ' + fmtSta(u.to) + ' (' + Math.round(u.to - u.from) + ' m)');
|
||||
if (u.volume != null) lines.push('부피 ' + Number(u.volume).toFixed(2) + ' m³');
|
||||
if (NOTE[u.group]) lines.push(NOTE[u.group]);
|
||||
lines.push('부품 ID ' + u.id);
|
||||
return lines.join('\n');
|
||||
|
||||
function showTip(u, x, y) {
|
||||
tipEl.replaceChildren();
|
||||
const t = document.createElement('p'); t.className = 'tip-title';
|
||||
t.textContent = u.group + (u.pattern ? ' · 지보패턴 ' + u.pattern : '');
|
||||
const dl = document.createElement('dl'); dl.className = 'spec';
|
||||
fillSpec(dl, specRows(u).filter((r) => r[0] !== '구분' && r[0] !== '지보패턴'));
|
||||
tipEl.append(t, dl);
|
||||
tipEl.style.display = 'block';
|
||||
const gap = 14;
|
||||
let left = x + gap, top = y + gap;
|
||||
if (left + tipEl.offsetWidth > innerWidth) left = x - tipEl.offsetWidth - gap;
|
||||
if (top + tipEl.offsetHeight > innerHeight) top = y - tipEl.offsetHeight - gap;
|
||||
tipEl.style.left = Math.max(8, left) + 'px';
|
||||
tipEl.style.top = Math.max(8, top) + 'px';
|
||||
}
|
||||
function hideTip() { tipEl.style.display = 'none'; }
|
||||
|
||||
Promise.all([
|
||||
fetch(META).then((r) => r.json()),
|
||||
@@ -190,50 +524,75 @@ Promise.all([
|
||||
]).then(([meta, gltf]) => {
|
||||
for (const c of meta.components) META_BY_ID[c.id] = c;
|
||||
world = new THREE.Group();
|
||||
world.rotation.x = -Math.PI / 2; // model is Z-up (height on z); view is Y-up
|
||||
world.rotation.x = -Math.PI / 2; // 모델은 Z-up, 화면은 Y-up
|
||||
world.add(gltf.scene);
|
||||
scene.add(world);
|
||||
gltf.scene.traverse((o) => {
|
||||
if (!o.isMesh) return;
|
||||
const c = META_BY_ID[o.name] || {};
|
||||
o.userData = { id: o.name, group: c.group || '', pattern: c.pattern || null,
|
||||
from: c.from_sta ?? null, to: c.to_sta ?? null, volume: c.volume_m3 ?? null };
|
||||
from: c.from_sta ?? null, to: c.to_sta ?? null, volume: c.volume_m3 ?? null,
|
||||
idx: meshes.length };
|
||||
o.geometry = o.geometry.toNonIndexed();
|
||||
o.geometry.computeVertexNormals();
|
||||
const rgb = c.color || [200, 200, 200];
|
||||
o.material = new THREE.MeshStandardMaterial({ color: new THREE.Color(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255),
|
||||
roughness: 0.8, metalness: 0.0, side: THREE.DoubleSide, flatShading: true });
|
||||
const p = PART[o.userData.group];
|
||||
const mat = new THREE.MeshStandardMaterial({ roughness: 0.8, metalness: 0.0, side: THREE.DoubleSide, flatShading: true });
|
||||
if (p && p.ghost) { mat.transparent = true; mat.opacity = 0.28; mat.depthWrite = false; }
|
||||
o.material = mat;
|
||||
meshes.push(o);
|
||||
});
|
||||
world.updateMatrixWorld(true);
|
||||
refreshColors();
|
||||
|
||||
// 지보패턴 체크박스
|
||||
const pats = [...new Set(meta.components.filter((x) => x.pattern).map((x) => x.pattern))];
|
||||
for (const p of pats) {
|
||||
patternOn[p] = true;
|
||||
const lab = document.createElement('label'); const cb = document.createElement('input');
|
||||
cb.type = 'checkbox'; cb.checked = true;
|
||||
const lab = document.createElement('label'); lab.className = 'opt';
|
||||
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true;
|
||||
cb.addEventListener('change', () => { patternOn[p] = cb.checked; applyVisibility(); });
|
||||
lab.append(cb, document.createTextNode(p)); $('pats').appendChild(lab);
|
||||
const span = document.createElement('span'); span.textContent = p;
|
||||
lab.append(cb, span); $('pats').appendChild(lab);
|
||||
}
|
||||
const groups = [['노면(계획고)', '노면(계획고)'], ['기층·인버트(가정)', '기층·인버트 (가정)'], ['배수(측구)', '배수(측구)'],
|
||||
['공동구(가정)', '공동구 (가정)'], ['시설한계(참조선)', '시설한계 50 mm 참조선']];
|
||||
for (const [key, text] of groups) {
|
||||
|
||||
// 구성요소 체크박스 (색 견본 = 부품 토큰)
|
||||
for (const [key, text] of TOGGLES) {
|
||||
groupOn[key] = true;
|
||||
const lab = document.createElement('label'); const cb = document.createElement('input');
|
||||
cb.type = 'checkbox'; cb.checked = true;
|
||||
const lab = document.createElement('label'); lab.className = 'opt';
|
||||
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true;
|
||||
cb.addEventListener('change', () => { groupOn[key] = cb.checked; applyVisibility(); });
|
||||
lab.append(cb, document.createTextNode(text)); $('comps').appendChild(lab);
|
||||
const sw = document.createElement('span'); sw.className = 'sw' + (PART[key].ghost ? ' ghost' : '');
|
||||
sw.setAttribute('aria-hidden', 'true'); sw.style.background = 'var(' + PART[key].token + ')';
|
||||
const span = document.createElement('span'); span.textContent = text;
|
||||
lab.append(cb, sw, span); $('comps').appendChild(lab);
|
||||
}
|
||||
|
||||
// 키보드용 부품 목록 (그룹 → 측점 순)
|
||||
const order = [...meshes].sort((a, b) =>
|
||||
(PART[a.userData.group]?.order ?? 9) - (PART[b.userData.group]?.order ?? 9) ||
|
||||
(a.userData.from ?? 0) - (b.userData.from ?? 0) || a.userData.id.localeCompare(b.userData.id));
|
||||
for (const m of order) {
|
||||
const u = m.userData;
|
||||
const o = document.createElement('option');
|
||||
o.value = String(u.idx);
|
||||
o.textContent = u.group + (u.pattern ? ' · ' + u.pattern : '') +
|
||||
(u.from != null ? ' · ' + fmtSta(u.from) + '~' + fmtSta(u.to) : '') + ' · ' + u.id;
|
||||
$('pickSel').appendChild(o);
|
||||
}
|
||||
|
||||
startView();
|
||||
statusEl.textContent = '로드 완료 · 부품 ' + meshes.length + '개';
|
||||
$('metaCount').textContent = String(meshes.length);
|
||||
statusEl.textContent = '로드 완료';
|
||||
}).catch((e) => { statusEl.textContent = '로드 실패: HTTP 서버에서 여세요 (' + e.message + ')'; });
|
||||
|
||||
// ---- station window (two handles) ----
|
||||
// ── 구간 슬라이더 (두 핸들) ──
|
||||
function syncWin(from) {
|
||||
let a = Number($('s0').value), b = Number($('s1').value);
|
||||
if (a > b) { if (from === 0) { b = a; $('s1').value = b; } else { a = b; $('s0').value = a; } }
|
||||
winLo = a; winHi = b;
|
||||
$('staText').textContent = fmtSta(a) + ' ~ ' + fmtSta(b);
|
||||
$('s0').setAttribute('aria-valuetext', fmtSta(a));
|
||||
$('s1').setAttribute('aria-valuetext', fmtSta(b));
|
||||
applyVisibility();
|
||||
}
|
||||
$('s0').addEventListener('input', () => syncWin(0));
|
||||
@@ -242,18 +601,21 @@ $('btnWin').addEventListener('click', () => frameWindow());
|
||||
$('btnWinReset').addEventListener('click', () => { $('s0').value = S_MIN; $('s1').value = S_MAX; syncWin(0); frameAll(); });
|
||||
$('btnStart').addEventListener('click', () => startView());
|
||||
$('btnAll').addEventListener('click', () => frameAll());
|
||||
$('showExc').addEventListener('change', applyVisibility);
|
||||
|
||||
// ---- selection tools ----
|
||||
// ── 선택 도구 ──
|
||||
$('btnFitSel').addEventListener('click', () => { if (selected) frameBox(new THREE.Box3().setFromObject(selected), 0.9); });
|
||||
$('btnDesel').addEventListener('click', () => setSelected(null));
|
||||
$('btnHideSel').addEventListener('click', () => { if (selected) { hiddenSel.add(selected); setSelected(null); } });
|
||||
$('btnDesel').addEventListener('click', () => { setSelected(null); hideTip(); });
|
||||
$('btnHideSel').addEventListener('click', () => { if (selected) { hiddenSel.add(selected); setSelected(null); hideTip(); } });
|
||||
$('btnIsoSel').addEventListener('click', () => { if (selected) { isolate = true; applyVisibility(); } });
|
||||
$('btnShowAll').addEventListener('click', () => { hiddenSel.clear(); isolate = false; applyVisibility(); });
|
||||
$('wire').addEventListener('change', () => { for (const m of meshes) m.material.wireframe = $('wire').checked; });
|
||||
addEventListener('keydown', (e) => { if (e.key === 'Escape') setSelected(null); });
|
||||
$('pickSel').addEventListener('change', () => {
|
||||
const v = $('pickSel').value;
|
||||
setSelected(v === '' ? null : meshes[Number(v)]);
|
||||
});
|
||||
addEventListener('keydown', (e) => { if (e.key === 'Escape') { setSelected(null); hideTip(); } });
|
||||
|
||||
// ---- pick ----
|
||||
// ── 포인터 선택 ──
|
||||
const ray = new THREE.Raycaster(); const ptr = new THREE.Vector2();
|
||||
let downAt = null;
|
||||
renderer.domElement.addEventListener('pointerdown', (e) => { downAt = [e.clientX, e.clientY]; });
|
||||
@@ -263,25 +625,11 @@ renderer.domElement.addEventListener('pointerup', (e) => {
|
||||
ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
|
||||
ray.setFromCamera(ptr, camera);
|
||||
const hit = ray.intersectObjects(meshes.filter((m) => m.visible), false)[0];
|
||||
if (!hit) { setSelected(null); return; }
|
||||
if (!hit) { setSelected(null); hideTip(); return; }
|
||||
setSelected(hit.object);
|
||||
tipEl.innerHTML = tipText(hit.object.userData).replace(/\n/g, '<br>');
|
||||
tipEl.style.display = 'block';
|
||||
tipEl.style.left = Math.min(e.clientX + 14, innerWidth - 340) + 'px';
|
||||
tipEl.style.top = Math.min(e.clientY + 14, innerHeight - 160) + 'px';
|
||||
showTip(hit.object.userData, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// keep the model centred in the area right of the control panel (wide screens only)
|
||||
function shiftForPanel() {
|
||||
const off = innerWidth > 760 ? -170 : 0;
|
||||
camera.setViewOffset(innerWidth, innerHeight, off, 0, innerWidth, innerHeight);
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
shiftForPanel();
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; shiftForPanel();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user