317 lines
10 KiB
JavaScript
317 lines
10 KiB
JavaScript
class CAD2DViewer {
|
|
constructor(canvasId, onSelectCallback) {
|
|
this.canvas = document.getElementById(canvasId);
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.onSelect = onSelectCallback;
|
|
|
|
this.drawingData = null;
|
|
this.activeLayers = new Set();
|
|
this.highlightedView = null;
|
|
this.needsFit = true;
|
|
|
|
// View transform: pan & zoom
|
|
this.scale = 1.0;
|
|
this.offsetX = 0;
|
|
this.offsetY = 0;
|
|
this.isDragging = false;
|
|
this.startX = 0;
|
|
this.startY = 0;
|
|
|
|
this.initEvents();
|
|
}
|
|
|
|
initEvents() {
|
|
this.canvas.addEventListener('mousedown', (e) => {
|
|
this.isDragging = true;
|
|
this.startX = e.clientX - this.offsetX;
|
|
this.startY = e.clientY - this.offsetY;
|
|
});
|
|
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (!this.isDragging) return;
|
|
this.offsetX = e.clientX - this.startX;
|
|
this.offsetY = e.clientY - this.startY;
|
|
this.render();
|
|
});
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
this.isDragging = false;
|
|
});
|
|
|
|
this.canvas.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.87;
|
|
const newScale = Math.max(0.1, Math.min(this.scale * zoomFactor, 30.0));
|
|
|
|
this.offsetX = mouseX - (mouseX - this.offsetX) * (newScale / this.scale);
|
|
this.offsetY = mouseY - (mouseY - this.offsetY) * (newScale / this.scale);
|
|
this.scale = newScale;
|
|
|
|
this.render();
|
|
});
|
|
|
|
this.canvas.addEventListener('click', (e) => {
|
|
if (Math.abs(e.clientX - (this.startX + this.offsetX)) > 5) return;
|
|
this.handleClick(e);
|
|
});
|
|
|
|
window.addEventListener('resize', () => this.resizeCanvas());
|
|
}
|
|
|
|
resizeCanvas() {
|
|
const parent = this.canvas.parentElement;
|
|
if (!parent) return;
|
|
const w = parent.clientWidth;
|
|
const h = parent.clientHeight;
|
|
if (w === 0 || h === 0) return;
|
|
|
|
this.canvas.width = w;
|
|
this.canvas.height = h;
|
|
if (this.needsFit) {
|
|
this.fitView();
|
|
this.needsFit = false;
|
|
} else {
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
loadDrawing(drawingData) {
|
|
if (!drawingData) return;
|
|
this.drawingData = drawingData;
|
|
this.activeLayers = new Set(Object.keys(drawingData.layers || {}));
|
|
this.highlightedView = null;
|
|
this.needsFit = true;
|
|
|
|
const parent = this.canvas.parentElement;
|
|
if (parent && parent.clientWidth > 0 && parent.clientHeight > 0) {
|
|
this.canvas.width = parent.clientWidth;
|
|
this.canvas.height = parent.clientHeight;
|
|
this.fitView();
|
|
this.needsFit = false;
|
|
}
|
|
}
|
|
|
|
setLayerVisibility(layerName, isVisible) {
|
|
if (isVisible) {
|
|
this.activeLayers.add(layerName);
|
|
} else {
|
|
this.activeLayers.delete(layerName);
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
fitView() {
|
|
if (!this.drawingData || !this.drawingData.bounds) return;
|
|
if (this.canvas.width === 0 || this.canvas.height === 0) {
|
|
this.needsFit = true;
|
|
return;
|
|
}
|
|
const b = this.drawingData.bounds;
|
|
const dwgW = Math.max(10, b.max_x - b.min_x);
|
|
const dwgH = Math.max(10, b.max_y - b.min_y);
|
|
|
|
const pad = 44;
|
|
const scaleX = (this.canvas.width - pad * 2) / dwgW;
|
|
const scaleY = (this.canvas.height - pad * 2) / dwgH;
|
|
this.scale = Math.max(0.1, Math.min(scaleX, scaleY));
|
|
|
|
this.offsetX = (this.canvas.width - dwgW * this.scale) / 2 - b.min_x * this.scale;
|
|
this.offsetY = (this.canvas.height + dwgH * this.scale) / 2 + b.min_y * this.scale;
|
|
|
|
this.render();
|
|
}
|
|
|
|
zoomToView(viewId) {
|
|
this.highlightedView = viewId;
|
|
if (!this.drawingData || !this.drawingData.views || !this.drawingData.views[viewId]) {
|
|
this.render();
|
|
return;
|
|
}
|
|
if (this.canvas.width > 0 && this.canvas.height > 0) {
|
|
const [bx0, by0, bx1, by1] = this.drawingData.views[viewId].bbox;
|
|
const vw = Math.max(10, bx1 - bx0);
|
|
const vh = Math.max(10, by1 - by0);
|
|
const pad = 60;
|
|
const scaleX = (this.canvas.width - pad * 2) / vw;
|
|
const scaleY = (this.canvas.height - pad * 2) / vh;
|
|
this.scale = Math.max(0.2, Math.min(scaleX, scaleY, 12.0));
|
|
this.offsetX = (this.canvas.width - vw * this.scale) / 2 - bx0 * this.scale;
|
|
this.offsetY = (this.canvas.height + vh * this.scale) / 2 + by0 * this.scale;
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
worldToScreen(x, y) {
|
|
return {
|
|
x: x * this.scale + this.offsetX,
|
|
y: -y * this.scale + this.offsetY
|
|
};
|
|
}
|
|
|
|
screenToWorld(sx, sy) {
|
|
return {
|
|
x: (sx - this.offsetX) / this.scale,
|
|
y: -(sy - this.offsetY) / this.scale
|
|
};
|
|
}
|
|
|
|
highlightView(viewId) {
|
|
this.highlightedView = viewId;
|
|
this.render();
|
|
}
|
|
|
|
handleClick(e) {
|
|
if (!this.drawingData || !this.drawingData.views) return;
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const world = this.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
|
|
for (const [vid, view] of Object.entries(this.drawingData.views)) {
|
|
const [bx0, by0, bx1, by1] = view.bbox;
|
|
if (world.x >= bx0 && world.x <= bx1 && world.y >= by0 && world.y <= by1) {
|
|
this.highlightedView = vid;
|
|
this.render();
|
|
if (view.wingwall && this.onSelect) {
|
|
this.onSelect(view.wingwall);
|
|
} else if (vid === 'PLAN' && this.onSelect) {
|
|
this.onSelect('WW_END_LEFT');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const ctx = this.ctx;
|
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
if (!this.drawingData) return;
|
|
|
|
ctx.lineWidth = Math.max(1.0, 0.75 * this.scale);
|
|
|
|
for (const ent of this.drawingData.entities || []) {
|
|
if (!this.activeLayers.has(ent.layer)) continue;
|
|
|
|
ctx.strokeStyle = ent.color || '#cccccc';
|
|
ctx.fillStyle = ent.color || '#cccccc';
|
|
|
|
if (ent.type === 'LINE') {
|
|
const p1 = this.worldToScreen(ent.p1[0], ent.p1[1]);
|
|
const p2 = this.worldToScreen(ent.p2[0], ent.p2[1]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(p1.x, p1.y);
|
|
ctx.lineTo(p2.x, p2.y);
|
|
ctx.stroke();
|
|
} else if (ent.type === 'LWPOLYLINE') {
|
|
if (!ent.points || ent.points.length === 0) continue;
|
|
ctx.beginPath();
|
|
const p0 = this.worldToScreen(ent.points[0][0], ent.points[0][1]);
|
|
ctx.moveTo(p0.x, p0.y);
|
|
for (let i = 1; i < ent.points.length; i++) {
|
|
const pt = this.worldToScreen(ent.points[i][0], ent.points[i][1]);
|
|
ctx.lineTo(pt.x, pt.y);
|
|
}
|
|
if (ent.closed) ctx.closePath();
|
|
ctx.stroke();
|
|
} else if (ent.type === 'TEXT') {
|
|
const pos = this.worldToScreen(ent.pos[0], ent.pos[1]);
|
|
const fontSize = Math.max(7, ent.height * this.scale * 0.85);
|
|
if (fontSize < 3.5) continue;
|
|
ctx.font = `${Math.min(fontSize, 26)}px -apple-system, "Malgun Gothic", sans-serif`;
|
|
ctx.fillText(ent.text, pos.x, pos.y);
|
|
} else if (ent.type === 'ARC' || ent.type === 'CIRCLE') {
|
|
const c = this.worldToScreen(ent.center[0], ent.center[1]);
|
|
const r = ent.radius * this.scale;
|
|
ctx.beginPath();
|
|
if (ent.type === 'CIRCLE') {
|
|
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
|
|
} else {
|
|
const sAngle = -THREE.MathUtils.degToRad(ent.end_angle);
|
|
const eAngle = -THREE.MathUtils.degToRad(ent.start_angle);
|
|
ctx.arc(c.x, c.y, r, sAngle, eAngle);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Draw View Boundary Boxes & Labels
|
|
for (const [vid, view] of Object.entries(this.drawingData.views || {})) {
|
|
const [bx0, by0, bx1, by1] = view.bbox;
|
|
const pTopLeft = this.worldToScreen(bx0, by1);
|
|
const pBotRight = this.worldToScreen(bx1, by0);
|
|
const w = pBotRight.x - pTopLeft.x;
|
|
const h = pBotRight.y - pTopLeft.y;
|
|
|
|
const isHigh = (this.highlightedView === vid);
|
|
ctx.strokeStyle = isHigh ? '#38bdf8' : (view.is_excluded ? '#ef4444' : 'rgba(148, 163, 184, 0.45)');
|
|
ctx.lineWidth = isHigh ? 2.5 : 1.0;
|
|
ctx.setLineDash(view.is_excluded ? [4, 4] : (isHigh ? [] : [3, 3]));
|
|
ctx.strokeRect(pTopLeft.x, pTopLeft.y, w, h);
|
|
ctx.setLineDash([]);
|
|
|
|
if (isHigh) {
|
|
ctx.fillStyle = 'rgba(56, 189, 248, 0.06)';
|
|
ctx.fillRect(pTopLeft.x, pTopLeft.y, w, h);
|
|
}
|
|
|
|
ctx.fillStyle = isHigh ? '#38bdf8' : (view.is_excluded ? '#ef4444' : '#94a3b8');
|
|
ctx.font = 'bold 11px -apple-system, "Malgun Gothic", sans-serif';
|
|
ctx.fillText(view.name, pTopLeft.x + 6, pTopLeft.y + 15);
|
|
}
|
|
|
|
// Draw Plan View Culvert & Wingwall Quadrant Overlay if available
|
|
const planView = this.drawingData.views?.PLAN;
|
|
const det = this.drawingData.structuralParams?.detection || planView?.detection;
|
|
if (planView && det && det.culvert_bounds) {
|
|
const cb = det.culvert_bounds;
|
|
const midY = cb.center_y || 416.5;
|
|
const startX = cb.start_x || 106.91;
|
|
const endX = cb.end_x || 162.93;
|
|
|
|
const pC0 = this.worldToScreen(startX, cb.top_dashed_y || 426.5);
|
|
const pC1 = this.worldToScreen(endX, cb.bot_dashed_y || 406.5);
|
|
const cW = pC1.x - pC0.x;
|
|
const cH = pC1.y - pC0.y;
|
|
|
|
ctx.strokeStyle = 'rgba(56, 189, 248, 0.7)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.strokeRect(pC0.x, pC0.y, cW, cH);
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.fillStyle = 'rgba(56, 189, 248, 0.08)';
|
|
ctx.fillRect(pC0.x, pC0.y, cW, cH);
|
|
|
|
ctx.fillStyle = '#38bdf8';
|
|
ctx.font = 'bold 10px -apple-system, "Malgun Gothic", sans-serif';
|
|
ctx.fillText('🏢 암거 판별 (내공 2.0m & 점선)', pC0.x + 4, pC0.y - 4);
|
|
|
|
// Label detected wingwalls
|
|
const quads = det.quadrants || {};
|
|
if (quads.END_LEFT?.exists) {
|
|
const pEL = this.worldToScreen(endX + 5, midY + 28);
|
|
ctx.fillStyle = '#38bdf8';
|
|
ctx.fillText('🟢 종점부 좌측 날개벽 (L=4.35m, 134°)', pEL.x, pEL.y);
|
|
}
|
|
if (quads.END_RIGHT?.exists) {
|
|
const pER = this.worldToScreen(endX + 5, midY - 32);
|
|
ctx.fillStyle = '#fbbf24';
|
|
ctx.fillText('🟢 종점부 우측 날개벽 (L=5.40m, 151°)', pER.x, pER.y);
|
|
}
|
|
if (!quads.START_LEFT?.exists) {
|
|
const pSL = this.worldToScreen(startX - 50, midY + 28);
|
|
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
|
|
ctx.fillText('⚪ 시점부 좌측 (미표기)', pSL.x, pSL.y);
|
|
}
|
|
if (!quads.START_RIGHT?.exists) {
|
|
const pSR = this.worldToScreen(startX - 50, midY - 32);
|
|
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
|
|
ctx.fillText('⚪ 시점부 우측 (미표기)', pSR.x, pSR.y);
|
|
}
|
|
}
|
|
}
|
|
}
|