한국어 3D 단면 뷰어와 객체 선택 숨김 및 도면 근거 패널 구현
This commit is contained in:
1 parent
9072507868
commit
093338256c
3 files changed
+188
-104
No files matched your search
+57
-58
@@ -1,60 +1,59 @@
|
||||
const stages = [
|
||||
{ number: '01', title: '도면 다시 읽기', detail: '평면 선형, 종단 표고, A-A/B-B 단면 위치와 축척 확인', state: '1차 분석 완료' },
|
||||
{ number: '02', title: '주요 구조 만들기', detail: '확인된 중심선과 단면값으로 바닥판·벽체 형상 구성', state: '대기' },
|
||||
{ number: '03', title: '세부 형상과 뷰어', detail: '부재 선택, 숨김, 시점 전환과 치수 표시 추가', state: '대기' },
|
||||
]
|
||||
import { useMemo, useState } from 'react'
|
||||
import Viewer from './Viewer'
|
||||
import { model, groups, palette, partDimensions, exportModel } from './model/geometry'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<main className="shell">
|
||||
<header className="topbar">
|
||||
<a className="brand" href="#top"><span className="brand-icon">G</span>도면 모델 작업실</a>
|
||||
<div className="project-label"><span className="live-dot" />새 프로젝트 <b>/</b> 광터지하차도 BLOCK7</div>
|
||||
<a className="drawing-link" href="/drawings/광터지하차도_일반도(BLOCK7).dxf" download>원본 도면 받기 <span>↓</span></a>
|
||||
</header>
|
||||
|
||||
<section className="intro" id="top">
|
||||
<div>
|
||||
<span className="kicker">새로 시작하는 도면 기반 모델</span>
|
||||
<h1>광터지하차도 BLOCK7</h1>
|
||||
<p>기존 모델을 가져오지 않고, 원본 도면을 다시 확인해 구조와 배치를 새로 구성합니다.</p>
|
||||
</div>
|
||||
<span className="status-pill">기준선 재확인 필요</span>
|
||||
</section>
|
||||
|
||||
<section className="workspace">
|
||||
<div className="viewer-card">
|
||||
<div className="viewer-head">
|
||||
<div><span className="eyebrow">3D 미리보기</span><h2>모델 영역</h2></div>
|
||||
<span className="review-tag">도면 검토용</span>
|
||||
</div>
|
||||
<div className="empty-viewer">
|
||||
<div className="axis-mark"><i /><i /><i /></div>
|
||||
<strong>주요 형상 모델링 전입니다</strong>
|
||||
<p>BLOCK7 기준선과 A-A/B-B 위치를 최종 대조한 뒤 형상을 만들 예정입니다.</p>
|
||||
</div>
|
||||
<div className="viewer-foot"><span>원본 도면 기준으로 치수와 위치를 확인합니다</span><span>단위: m</span></div>
|
||||
</div>
|
||||
|
||||
<aside className="side-card">
|
||||
<span className="eyebrow">프로젝트 단계</span>
|
||||
<h2>처음부터 차근차근</h2>
|
||||
<div className="stage-list">
|
||||
{stages.map((stage) => (
|
||||
<article className="stage" key={stage.number}>
|
||||
<span className="stage-number">{stage.number}</span>
|
||||
<div><strong>{stage.title}</strong><p>{stage.detail}</p></div>
|
||||
<span className="stage-state">{stage.state}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="source-note"><strong>참고 자료</strong><p>이전 분석 문서는 참고용으로만 보관했습니다. 새 모델의 기준은 원본 DXF를 다시 확인해 정합니다.</p></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<footer>추정값은 확인된 도면 치수와 구분해 기록합니다.</footer>
|
||||
</main>
|
||||
)
|
||||
function download(data, filename, type = 'application/json') {
|
||||
const url = URL.createObjectURL(new Blob([data], { type }))
|
||||
const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default function App() {
|
||||
const [hidden, setHidden] = useState(new Set()), [selected, setSelected] = useState(null)
|
||||
const [view, setView] = useState({ name: '전체', key: 0 }), [repeat, setRepeat] = useState(false)
|
||||
const [section, setSection] = useState('both'), [dimensions, setDimensions] = useState(true), [renderMode, setRenderMode] = useState(false)
|
||||
const [search, setSearch] = useState(''), [tab, setTab] = useState('부재'), [source, setSource] = useState('단면A')
|
||||
const part = model.parts.find(p => p.id === selected), dims = part && partDimensions(part)
|
||||
const shown = model.parts.filter(p => !hidden.has(p.id) && (repeat || !p.repeat)).length
|
||||
const filtered = useMemo(() => model.parts.filter(p => `${p.name} ${p.layer}`.includes(search)), [search])
|
||||
const toggle = ids => setHidden(current => { const next = new Set(current), hide = ids.some(id => !current.has(id)); ids.forEach(id => hide ? next.add(id) : next.delete(id)); return next })
|
||||
const switchView = name => { setView({ name, key: Date.now() }); if (name === 'A-A') setSection('A'); else if (name === 'B-B') setSection('B'); else setSection('both') }
|
||||
return <main className="shell">
|
||||
<header className="topbar">
|
||||
<a className="brand" href="#top"><span className="brand-icon">G</span>도면 모델 작업실</a>
|
||||
<span className="project-label">구조 검토 <b>/</b> 일반도 13 · 14</span>
|
||||
<a className="drawing-link" href="./drawings/광터지하차도_일반도(BLOCK7).dxf" download>원본 DXF ↓</a>
|
||||
<button className="export" onClick={() => download(JSON.stringify(exportModel(hidden), null, 2), '광터_BLOCK7_단면모델.json')}>모델 데이터 ↓</button>
|
||||
</header>
|
||||
<section className="intro" id="top">
|
||||
<div><span className="kicker">도면에서 읽은 구조, 근거가 보이는 모델</span><h1>광터지하차도 <em>BLOCK7</em></h1><p>원본 단면 윤곽을 보존한 3D 검토 모델</p></div>
|
||||
<span className="status-pill"><i />평면 배치 · 단면 연결 확인 필요</span>
|
||||
</section>
|
||||
<section className="metrics" aria-label="도면 기준값">
|
||||
<article><small>종단 구조 경계 간격</small><strong>19.980 <span>m</span></strong><p>STA 299.010 → 318.990 · 계산값</p></article>
|
||||
<article><small>단면 표기 폭</small><strong>20.100 <span>m</span></strong><p>일반도 14 · 도면 명시값</p></article>
|
||||
<article><small>벽체 상단 EL · A / B</small><strong className="el">106.675 <span>/</span> 107.307</strong><p>종단면 · 단면 표기 일치</p></article>
|
||||
<article><small>원본 윤곽 객체</small><strong>154 <span>쌍</span></strong><p>구조선 114 + 좌우 블록 40</p></article>
|
||||
</section>
|
||||
<div className="notice"><span>확인 필요</span><p>현재는 두 단면을 각 도면 측점에 배치한 윤곽 모델입니다. 단면 STA 299.000 / 319.000과 종단 경계의 10mm 차이, 평면 중심선 및 중심 EL을 확인해야 전체 연결 형상을 확정할 수 있습니다.</p></div>
|
||||
<section className="workspace">
|
||||
<div className="viewer-card">
|
||||
<div className="viewer-head"><div><span className="eyebrow">모델 검토</span><h2>단면에서 공간으로</h2></div><div className="segmented"><button className={!renderMode ? 'active' : ''} onClick={() => setRenderMode(false)}>기본 모델</button><button className={renderMode ? 'active' : ''} onClick={() => setRenderMode(true)}>윤곽 명암</button></div></div>
|
||||
<div className="viewbar"><div className="views">{['전체', '평면', '정면', '측면', 'A-A', 'B-B'].map(name => <button className={view.name === name ? 'active' : ''} key={name} onClick={() => switchView(name)}>{name}</button>)}</div><label><input type="checkbox" checked={dimensions} onChange={e => setDimensions(e.target.checked)} />치수</label></div>
|
||||
<div className="canvas-wrap"><Viewer {...{ hidden, selected, view, repeat, section, dimensions, renderMode }} onSelect={setSelected} /><div className="canvas-tag">{renderMode ? '윤곽 명암 · 도면 검토용 시각화' : '원본 단면 윤곽 · 단위 m'}</div><div className="model-note">평면 위치 미확정 · Z축은 단면 측점 차</div></div>
|
||||
<div className="viewer-foot"><span>끌기 회전 · 휠 확대 · Shift+끌기 이동 · 두 손가락 확대/이동</span><button onClick={() => switchView('전체')}>전체 보기 ↗</button></div>
|
||||
<div className="selection" aria-live="polite">{part ? <><div className="selection-title"><span className="eyebrow">선택한 윤곽</span><button onClick={() => setSelected(null)} aria-label="선택 해제">×</button></div><h3>{part.name}</h3><div className="selection-grid"><p>횡방향 범위<strong>{dims.width.toFixed(3)} m</strong></p><p>표고 범위 EL<strong>{dims.elevation.map(v => v.toFixed(3)).join(' ~ ')}</strong></p></div><p className="source">{part.source}<br />원본 선 번호: {part.handles.join(' / ')}</p><p className="source">치수는 원본 좌표로 계산한 값입니다. 선의 범위이며 부재 두께·길이를 확정한 값이 아닙니다.</p><button className="subtle" onClick={() => toggle([part.id])}>선택한 윤곽 숨기기</button></> : <><span className="selection-dot" /><div><strong>윤곽을 선택해 근거를 확인하세요</strong><p>화면의 선이나 오른쪽 목록을 선택하면 이름·치수·원본 선 번호를 보여줍니다.</p></div></>}</div>
|
||||
</div>
|
||||
<aside className="side-card">
|
||||
<div className="tabs">{['부재', '도면', '검증'].map(t => <button key={t} className={tab === t ? 'active' : ''} onClick={() => setTab(t)}>{t}</button>)}</div>
|
||||
{tab === '부재' && <><div className="panel-heading"><h2>윤곽 목록</h2><span>{shown} / {model.parts.length} 표시</span></div><input className="search" aria-label="윤곽 검색" placeholder="부재 이름 또는 레이어 검색" value={search} onChange={e => setSearch(e.target.value)} /><label className="repeat"><input type="checkbox" checked={repeat} onChange={e => setRepeat(e.target.checked)} />포장 반복선·내부선 표시</label><div className="list-actions"><button onClick={() => setHidden(new Set())}>모두 표시</button><button onClick={() => setHidden(new Set(model.parts.map(p => p.id)))}>모두 숨김</button></div><div className="part-list">{groups.map((g, i) => {
|
||||
const ps = filtered.filter(p => p.group === g)
|
||||
if (!ps.length) return null
|
||||
const allHidden = ps.every(p => hidden.has(p.id))
|
||||
return <details key={g} open={search ? true : undefined}><summary><span className="swatch" style={{ background: palette[i % palette.length] }} /><strong>{g}</strong><small>{ps.length}</small></summary><div className="group-actions"><button onClick={() => toggle(ps.map(p => p.id))}>{allHidden ? '그룹 표시' : '그룹 숨김'}</button></div>{ps.map(p => <div className={`part-row ${selected === p.id ? 'chosen' : ''}`} key={p.id}><button onClick={() => setSelected(p.id)} title={p.name}>{p.name}</button><input aria-label={`${p.name} 표시`} type="checkbox" checked={!hidden.has(p.id)} onChange={() => toggle([p.id])} /></div>)}</details>
|
||||
})}{!filtered.length && <p className="empty">검색 결과가 없습니다.</p>}</div><p className="panel-note">선 개수는 실제 부재 개수와 다릅니다. 반복선은 기본적으로 숨겨져 있습니다.</p></>}
|
||||
{tab === '도면' && <><h2>원본 좌표 검토</h2><p className="panel-note">주요 LINE·TEXT와 공동구 선을 발췌한 검토 자료입니다. 전체 도면은 원본 DXF를 확인하세요.</p><div className="source-tabs">{['단면A', '단면B', '평면검토', '종단검토', '측구상세'].map(s => <button key={s} className={s === source ? 'active' : ''} onClick={() => setSource(s)}>{s}</button>)}</div><a href={`./analysis/${source}.svg`} target="_blank" rel="noreferrer"><img className="drawing-preview" src={`./analysis/${source}.svg`} alt={`${source} 원본 좌표 발췌`} /><span className="open-drawing">크게 보기 ↗</span></a><div className="source-note"><strong>그림 읽는 법</strong><p>녹색은 두 콘크리트 레이어, 황토색은 공동구 삽입 블록, 회색은 보조선입니다. 도면 기호·일부 블록은 생략되어 있습니다.</p></div></>}
|
||||
{tab === '검증' && <><h2>확인한 것과 남은 것</h2><ul className="checks"><li><b>확인</b><span>A/B 벽체 상단 EL<br /><small>종단면과 단면 각각 일치</small></span></li><li><b>확인</b><span>단면별 구조선 114개<br /><small>MAJR 108 + MINR 6, 원본 번호 보존</small></span></li><li><b>확인</b><span>공동구 실제 직선·원호<br /><small>좌우 반전과 단면별 높이 반영</small></span></li><li className="pending"><b>미확정</b><span>평면 중심선·절단 위치<br /><small>중심 EL·중간 곡률 미검증</small></span></li><li className="pending"><b>미확정</b><span>단면·종단 측점 차이<br /><small>각 끝 10mm 차이, 경계 대응 필요</small></span></li><li className="pending"><b>미확정</b><span>연결 표면·체적·재료<br /><small>실제 중간 형상과 상세 연장 미검증</small></span></li></ul><div className="source-note"><strong>현재 표현 범위</strong><p>두 단면의 구조선과 공동구 윤곽을 공간에 배치했습니다. 표면·그림자를 적용한 렌더링은 연결 형상 확인 후 추가할 항목입니다. 현재 윤곽 명암은 실사 렌더링이 아닙니다.</p></div></>}
|
||||
</aside>
|
||||
</section>
|
||||
<footer><span>광터지하차도 BLOCK7 · 도면 검토용</span><span>기준 단위 m · 표고 EL · 미확정 형상은 분석 기록에서 관리</span></footer>
|
||||
</main>
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { model, groups, palette, profileVertices } from './model/geometry'
|
||||
|
||||
const initial = () => ({ yaw: -.65, pitch: .56, zoom: 1, pan: [0, 0] })
|
||||
const distance = (p, a, b) => {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1]
|
||||
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1)))
|
||||
return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)
|
||||
}
|
||||
export default function Viewer({ hidden, selected, onSelect, view, repeat, section, dimensions, renderMode, onReady }) {
|
||||
const canvasRef = useRef(null), camera = useRef(initial()), drawRef = useRef(() => {})
|
||||
const scene = useRef({ hidden, selected, onSelect, repeat, section, dimensions, renderMode })
|
||||
useEffect(() => { scene.current = { hidden, selected, onSelect, repeat, section, dimensions, renderMode }; drawRef.current() }, [hidden, selected, onSelect, repeat, section, dimensions, renderMode])
|
||||
useEffect(() => {
|
||||
camera.current = { ...initial(), ...({ '평면': { yaw: 0, pitch: Math.PI / 2 }, '정면': { yaw: 0, pitch: 0 }, '측면': { yaw: Math.PI / 2, pitch: 0 }, 'A-A': { yaw: 0, pitch: 0 }, 'B-B': { yaw: Math.PI, pitch: 0 } }[view.name]) }
|
||||
drawRef.current()
|
||||
}, [view])
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current, ctx = canvas.getContext('2d')
|
||||
const pointers = new Map()
|
||||
let hits = [], moved = false, last = null, start = null
|
||||
const project = (p, width, height) => {
|
||||
const c = camera.current, y = p[1] - 4
|
||||
const x = p[0] * Math.cos(c.yaw) - p[2] * Math.sin(c.yaw)
|
||||
const z = p[0] * Math.sin(c.yaw) + p[2] * Math.cos(c.yaw)
|
||||
const scale = Math.min(width / 35, height / 29) * c.zoom
|
||||
return [width / 2 + x * scale + c.pan[0], height / 2 - (y * Math.cos(c.pitch) - z * Math.sin(c.pitch)) * scale + c.pan[1], y * Math.sin(c.pitch) + z * Math.cos(c.pitch)]
|
||||
}
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect(), w = rect.width, h = rect.height
|
||||
if (!w || !h) return
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr) }
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const { hidden, selected, repeat, section, dimensions, renderMode } = scene.current
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, renderMode ? '#e7eeeb' : '#f8faf8'); bg.addColorStop(1, renderMode ? '#c8d5ce' : '#eaf0eb')
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h)
|
||||
ctx.lineWidth = .6; ctx.strokeStyle = '#aabcb347'
|
||||
const line = (a, b, color, width = 1) => { ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.stroke() }
|
||||
for (let k = -16; k <= 16; k += 2) {
|
||||
line(project([k, .5, -16], w, h), project([k, .5, 16], w, h), '#9faea943', .6)
|
||||
line(project([-16, .5, k], w, h), project([16, .5, k], w, h), '#9faea943', .6)
|
||||
}
|
||||
const edges = []
|
||||
model.parts.forEach(part => {
|
||||
if (hidden.has(part.id) || (!repeat && part.repeat)) return
|
||||
const color = palette[groups.indexOf(part.group) % palette.length]
|
||||
for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) {
|
||||
const pts = profileVertices(part, s).map(p => project(p, w, h))
|
||||
for (let i = 0; i < pts.length - 1; i++) edges.push({ a: pts[i], b: pts[i + 1], part, color, depth: (pts[i][2] + pts[i + 1][2]) / 2, s })
|
||||
}
|
||||
})
|
||||
edges.sort((a, b) => a.depth - b.depth)
|
||||
hits = []
|
||||
edges.forEach(({ a, b, part, color, s }) => {
|
||||
const active = selected === part.id
|
||||
line(a, b, active ? '#cf762f' : color, active ? 3.7 : part.repeat ? .6 : renderMode ? 2.8 : 1.7)
|
||||
if (renderMode && !part.repeat) { ctx.shadowColor = '#28453d40'; ctx.shadowBlur = 3; line(a, b, active ? '#e89d50' : color, 1); ctx.shadowBlur = 0 }
|
||||
hits.push({ a, b, id: part.id, s })
|
||||
})
|
||||
ctx.font = '12px sans-serif'
|
||||
for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) {
|
||||
const z = s ? 10 : -10
|
||||
const p = project([0, model.sectionTops[s] - 100 + .8, z], w, h)
|
||||
ctx.fillStyle = s ? '#836442' : '#376f60'; ctx.textAlign = 'center'
|
||||
ctx.fillText(`${s ? 'B-B' : 'A-A'} · STA ${model.sectionStations[s].toFixed(3)}`, p[0], p[1])
|
||||
if (dimensions) {
|
||||
const a = project([-10.05, .1, z], w, h), b = project([10.05, .1, z], w, h)
|
||||
line(a, b, '#6b8279', .8); ctx.fillStyle = '#557267'
|
||||
ctx.fillText('표기 폭 20.100m', (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 17)
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'left'; ctx.font = '11px sans-serif'; ctx.fillStyle = '#71877c'
|
||||
ctx.fillText('X 횡방향 / Y 표고 / Z 단면 측점 차', 18, h - 20)
|
||||
ctx.fillText(`확대 ${Math.round(camera.current.zoom * 100)}%`, w - 96, h - 20)
|
||||
onReady?.()
|
||||
}
|
||||
drawRef.current = draw
|
||||
const local = e => { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top] }
|
||||
const down = e => { canvas.focus(); canvas.setPointerCapture(e.pointerId); pointers.set(e.pointerId, local(e)); start = local(e); last = { p: local(e), distance: 0 }; moved = false; e.preventDefault() }
|
||||
const move = e => {
|
||||
if (!pointers.has(e.pointerId)) return
|
||||
const p = local(e), old = pointers.get(e.pointerId); pointers.set(e.pointerId, p)
|
||||
if (Math.hypot(p[0] - start[0], p[1] - start[1]) > 4) moved = true
|
||||
if (pointers.size === 2) {
|
||||
const [a, b] = [...pointers.values()], d = Math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
if (last.distance) camera.current.zoom = Math.min(8, Math.max(.25, camera.current.zoom * d / last.distance))
|
||||
camera.current.pan[0] += (p[0] - old[0]) / 2; camera.current.pan[1] += (p[1] - old[1]) / 2
|
||||
last.distance = d; moved = true
|
||||
} else if (e.shiftKey || e.buttons === 2 || e.buttons === 4) {
|
||||
camera.current.pan[0] += p[0] - old[0]; camera.current.pan[1] += p[1] - old[1]
|
||||
} else {
|
||||
camera.current.yaw += (p[0] - old[0]) * .009
|
||||
camera.current.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.current.pitch + (p[1] - old[1]) * .009))
|
||||
}
|
||||
draw()
|
||||
}
|
||||
const up = e => {
|
||||
const p = local(e)
|
||||
if (!moved && pointers.size === 1) {
|
||||
const hit = [...hits].reverse().find(h => distance(p, h.a, h.b) < 7)
|
||||
scene.current.onSelect(hit?.id || null)
|
||||
}
|
||||
pointers.delete(e.pointerId); last = { p, distance: 0 }
|
||||
}
|
||||
const cancel = e => { pointers.delete(e.pointerId); moved = true }
|
||||
const wheel = e => { e.preventDefault(); camera.current.zoom = Math.min(8, Math.max(.25, camera.current.zoom * Math.exp(-e.deltaY * .001))); draw() }
|
||||
const key = e => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '+', '=', '-', 'Home', 'Escape'].includes(e.key)) return
|
||||
e.preventDefault()
|
||||
if (e.key === 'Home') camera.current = initial()
|
||||
if (e.key === 'Escape') scene.current.onSelect(null)
|
||||
if (e.key === '+' || e.key === '=') camera.current.zoom = Math.min(8, camera.current.zoom * 1.15)
|
||||
if (e.key === '-') camera.current.zoom = Math.max(.25, camera.current.zoom / 1.15)
|
||||
if (e.key === 'ArrowLeft') camera.current.yaw -= .1
|
||||
if (e.key === 'ArrowRight') camera.current.yaw += .1
|
||||
if (e.key === 'ArrowUp') camera.current.pitch = Math.min(Math.PI / 2, camera.current.pitch + .1)
|
||||
if (e.key === 'ArrowDown') camera.current.pitch = Math.max(-Math.PI / 2, camera.current.pitch - .1)
|
||||
draw()
|
||||
}
|
||||
const context = e => e.preventDefault()
|
||||
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', cancel); canvas.addEventListener('wheel', wheel, { passive: false }); canvas.addEventListener('keydown', key); canvas.addEventListener('contextmenu', context)
|
||||
const observer = new ResizeObserver(draw); observer.observe(canvas); draw()
|
||||
return () => { observer.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', cancel); canvas.removeEventListener('wheel', wheel); canvas.removeEventListener('keydown', key); canvas.removeEventListener('contextmenu', context); drawRef.current = () => {} }
|
||||
}, [onReady])
|
||||
return <canvas ref={canvasRef} tabIndex={0} role="img" aria-label="광터지하차도 단면 3D 모델. 끌어서 회전, Shift와 끌기로 이동, 휠로 확대. 방향키로 회전, Home으로 전체 보기." />
|
||||
}
|
||||
|
||||
|
||||
+2
-46
@@ -1,46 +1,2 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Noto+Sans+KR:wght@400;500;600;700&display=swap');
|
||||
:root { font-family: 'DM Sans', 'Noto Sans KR', sans-serif; color: #26332e; background: #edf1ed; font-synthesis: none; text-rendering: optimizeLegibility; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.shell { max-width: 1440px; margin: 0 auto; padding: 0 36px 24px; }
|
||||
.topbar { height: 74px; display: flex; align-items: center; border-bottom: 1px solid #dce3dd; gap: 32px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 14px; }
|
||||
.brand-icon { display: grid; place-items: center; width: 30px; height: 30px; color: white; background: #597567; border-radius: 9px; }
|
||||
.project-label { margin-left: auto; color: #63736a; font-size: 12px; }
|
||||
.project-label b { color: #a4aea7; padding: 0 7px; }
|
||||
.live-dot { display: inline-block; width: 7px; height: 7px; background: #86a578; border-radius: 50%; margin-right: 7px; }
|
||||
.drawing-link { font-size: 12px; font-weight: 600; color: #456354; padding: 10px 13px; border: 1px solid #d6e0d8; border-radius: 9px; background: #f5f7f4; }
|
||||
.drawing-link span { margin-left: 8px; }
|
||||
.intro { padding: 38px 2px 26px; display: flex; justify-content: space-between; align-items: end; }
|
||||
.kicker, .eyebrow { display: block; color: #718078; text-transform: uppercase; letter-spacing: .08em; font-size: 10px; font-weight: 700; }
|
||||
h1 { font-size: 30px; letter-spacing: -.04em; margin: 8px 0 7px; }
|
||||
.intro p { color: #758179; font-size: 13px; margin: 0; }
|
||||
.status-pill, .review-tag { border: 1px solid #d4dfd6; color: #567061; background: #f6f8f5; padding: 8px 11px; font-size: 11px; font-weight: 600; border-radius: 20px; }
|
||||
.workspace { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(320px, .9fr); gap: 18px; }
|
||||
.viewer-card, .side-card { border: 1px solid #dce3dd; background: #f8faf7; border-radius: 15px; box-shadow: 0 10px 30px #354b3c0a; }
|
||||
.viewer-card { padding: 20px; }
|
||||
.viewer-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 17px; }
|
||||
h2 { margin: 5px 0 0; font-size: 17px; letter-spacing: -.025em; }
|
||||
.empty-viewer { height: min(58vh, 490px); min-height: 330px; border: 1px dashed #cbd6ce; border-radius: 11px; background: radial-gradient(ellipse at center, #f3f6f2 0, #e9eeea 100%); display: flex; align-items: center; justify-content: center; flex-direction: column; text-align: center; color: #58675e; }
|
||||
.empty-viewer strong { font-size: 14px; margin-top: 25px; }
|
||||
.empty-viewer p { color: #859088; font-size: 12px; margin: 8px 18px; }
|
||||
.axis-mark { position: relative; width: 54px; height: 46px; }
|
||||
.axis-mark i { position: absolute; left: 26px; bottom: 5px; width: 1px; height: 38px; background: #92a49a; transform-origin: bottom; }
|
||||
.axis-mark i:nth-child(2) { transform: rotate(90deg); }
|
||||
.axis-mark i:nth-child(3) { transform: rotate(-45deg); background: #b3a789; height: 27px; }
|
||||
.viewer-foot { display: flex; justify-content: space-between; color: #87928a; font-size: 10px; padding: 13px 2px 0; }
|
||||
.side-card { padding: 21px 19px; }
|
||||
.side-card h2 { font-size: 18px; margin: 7px 0 21px; }
|
||||
.stage-list { display: grid; }
|
||||
.stage { display: grid; grid-template-columns: 33px 1fr; gap: 10px; padding: 15px 0; border-top: 1px solid #e5eae5; }
|
||||
.stage-number { color: #84968a; font-size: 11px; font-weight: 700; padding-top: 2px; }
|
||||
.stage strong { font-size: 12px; }
|
||||
.stage p { margin: 5px 0 0; color: #7e8982; font-size: 11px; line-height: 1.6; }
|
||||
.stage-state { grid-column: 2; color: #88958c; font-size: 10px; margin-top: 5px; }
|
||||
.stage:first-child .stage-state { color: #5d7b68; }
|
||||
.source-note { margin-top: 15px; padding: 14px; border-radius: 10px; background: #eff3ee; }
|
||||
.source-note strong { font-size: 11px; }
|
||||
.source-note p { color: #768279; font-size: 10px; line-height: 1.7; margin: 6px 0 0; }
|
||||
footer { color: #849087; font-size: 10px; padding: 19px 3px 0; }
|
||||
@media (max-width: 820px) { .shell { padding: 0 18px 20px; } .topbar { height: 64px; gap: 10px; } .project-label { display: none; } .drawing-link { margin-left: auto; font-size: 10px; } .intro { padding-top: 28px; align-items: start; flex-direction: column; gap: 16px; } h1 { font-size: 25px; } .workspace { grid-template-columns: 1fr; } .empty-viewer { min-height: 290px; height: 45vh; } }
|
||||
:root{font-family:'Malgun Gothic','Apple SD Gothic Neo',sans-serif;color:#263a32;background:#eff2ed;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}body{margin:0;min-width:360px}button,input{font:inherit}button,a,input{outline-offset:4px}button{cursor:pointer}button{border:0;background:none;color:inherit}a{color:inherit;text-decoration:none}button:hover{color:#2b735c}button:focus-visible,a:focus-visible{outline:2px solid #bc763b}.shell{max-width:1600px;margin:auto;padding:0 36px 24px}.topbar{height:76px;display:flex;align-items:center;gap:18px;border-bottom:1px solid #d9e1d7}.brand{display:flex;gap:10px;align-items:center;font-size:14px;font-weight:700}.brand-icon{display:grid;place-items:center;width:32px;height:32px;border-radius:10px;background:#446b58;color:#fff;font-family:Georgia,serif;font-size:23px}.project-label{margin-left:auto;font-size:11px;color:#7a897e}.project-label b{margin:0 9px;color:#b1b9b1}.drawing-link,.export{padding:10px 13px;border:1px solid #d3ddd2;border-radius:7px;font-size:11px}.export{background:#426851;color:#fff;border-color:#426851}.export:hover{color:white;background:#31513e}.intro{display:flex;justify-content:space-between;align-items:center;padding:35px 0 26px}.kicker,.eyebrow{font-size:10px;letter-spacing:.08em;color:#7a8d7e;display:block;font-weight:700}h1{margin:8px 0;font-size:30px;letter-spacing:-.055em}h1 em{font-style:normal;font-weight:400;margin-left:8px;color:#738b79}.intro p{margin:0;color:#7c8d80;font-size:12px}.status-pill{display:flex;align-items:center;gap:7px;border:1px solid #e0d6bd;background:#f8f4e9;border-radius:30px;padding:9px 13px;font-size:11px;color:#9b793c}.status-pill i{width:6px;height:6px;background:#b08a45;border-radius:50%}.metrics{display:grid;grid-template-columns:repeat(4,1fr);background:#f9fbf7;border:1px solid #dce4d9;border-radius:12px;margin-bottom:18px;padding:20px 0}.metrics article{padding:0 23px;border-right:1px solid #e3e8df}.metrics article:last-child{border:0}.metrics small{display:block;font-size:10px;color:#7d8b7d}.metrics strong{font-family:Georgia,serif;font-size:28px;font-weight:400;display:block;margin:9px 0}.metrics strong.el{font-size:23px;line-height:32px}.metrics strong span{font:12px sans-serif;color:#899584}.metrics p{font-size:9px;margin:0;color:#8b9687}.notice{display:flex;gap:12px;align-items:center;background:#f5f0e4;border:1px solid #e6dcc7;border-radius:8px;padding:11px 15px;margin-bottom:20px}.notice>span{font-size:10px;font-weight:700;white-space:nowrap;color:#967136}.notice p{font-size:11px;line-height:1.75;margin:0;color:#8b7e62}.workspace{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:20px;align-items:start}.viewer-card,.side-card{background:#fafcf8;border:1px solid #dbe4d7;border-radius:12px;overflow:hidden}.viewer-head{display:flex;align-items:center;justify-content:space-between;padding:20px 22px 16px}h2{font-size:17px;letter-spacing:-.04em;margin:5px 0 0}.segmented{display:flex;padding:3px;background:#eaf0e7;border-radius:7px}.segmented button{font-size:10px;padding:7px 11px;border-radius:5px;color:#7f8d7b}.segmented .active{background:#fff;box-shadow:0 1px 4px #233c2912;color:#496c52}.viewbar{display:flex;justify-content:space-between;align-items:center;padding:0 20px 12px;gap:12px}.views{display:flex;gap:4px}.views button{font-size:10px;padding:7px 10px;border-radius:5px;border:1px solid transparent}.views button.active{border-color:#ccdac9;background:#eef3ea;color:#426a50}.viewbar label{display:flex;align-items:center;font-size:10px;color:#7e8c79;gap:4px}input[type=checkbox]{accent-color:#547862;cursor:pointer}.canvas-wrap{position:relative;border-top:1px solid #dfe8da;border-bottom:1px solid #dfe8da}.canvas-wrap canvas{display:block;width:100%;height:510px;touch-action:none;cursor:grab}.canvas-wrap canvas:active{cursor:grabbing}.canvas-wrap canvas:focus-visible{outline:2px solid #7d9a83;outline-offset:-2px}.canvas-tag{position:absolute;left:18px;top:17px;background:#fcfffbd9;border:1px solid #d5e0d2;border-radius:5px;color:#6c846f;font-size:9px;padding:7px 9px;pointer-events:none}.model-note{position:absolute;right:16px;top:23px;color:#aa824b;font-size:9px;pointer-events:none}.viewer-foot{display:flex;justify-content:space-between;align-items:center;padding:13px 20px;color:#8b9885;font-size:9px}.viewer-foot button{font-size:10px;color:#56735b}.selection{margin:0 20px 20px;border-top:1px solid #e2e9dd;min-height:94px;padding:18px 0 0;display:flex;align-items:center;gap:13px}.selection:has(h3){display:block}.selection strong{font-size:11px;font-weight:500}.selection p{font-size:10px;color:#899581;line-height:1.8;margin:7px 0}.selection-dot{width:10px;height:10px;border-radius:50%;border:2px solid #87a78c}.selection-title{display:flex;align-items:center;justify-content:space-between}.selection-title button{font-size:20px}.selection h3{font-size:15px;margin:5px 0}.selection-grid{display:flex;gap:35px}.selection-grid p strong{display:block;color:#4c6e53}.selLine truncated
|
||||
.viewer-card,.side-card,.metrics article{min-width:0}.canvas-wrap canvas{max-width:100%}.notice p{min-width:0}@media(max-width:480px){.topbar{height:auto;min-height:65px;flex-wrap:wrap;padding:14px 0}.export{margin-left:auto}.viewer-head{gap:8px}.segmented button{font-size:9px}.viewer-foot{gap:10px}.viewer-foot span{min-width:0}.metrics p{line-height:1.7}.notice{align-items:flex-start}}
|
||||
Reference in new issue
Block a user