| |
|
|
| export class Renderer { |
| constructor(canvas) { |
| this.canvas = canvas; |
| this.ctx = canvas.getContext('2d'); |
| this.scale = 1; |
| this.offsetX = 0; |
| this.offsetY = 0; |
| this.pageWidth = 0; |
| this.pageHeight = 0; |
| this.dpr = window.devicePixelRatio || 1; |
| } |
|
|
| resize(containerWidth, containerHeight) { |
| this.canvas.width = containerWidth * this.dpr; |
| this.canvas.height = containerHeight * this.dpr; |
| this.canvas.style.width = containerWidth + 'px'; |
| this.canvas.style.height = containerHeight + 'px'; |
| this.containerWidth = containerWidth; |
| this.containerHeight = containerHeight; |
| } |
|
|
| fitToView(pageWidth, pageHeight) { |
| this.pageWidth = pageWidth; |
| this.pageHeight = pageHeight; |
| const padding = 40; |
| const scaleX = (this.containerWidth - padding * 2) / pageWidth; |
| const scaleY = (this.containerHeight - padding * 2) / pageHeight; |
| this.scale = Math.min(scaleX, scaleY); |
| this.offsetX = (this.containerWidth - pageWidth * this.scale) / 2; |
| this.offsetY = (this.containerHeight - pageHeight * this.scale) / 2; |
| } |
|
|
| getZoomPercent() { |
| return Math.round(this.scale * 100); |
| } |
|
|
| zoom(delta, centerX, centerY) { |
| const oldScale = this.scale; |
| const factor = delta > 0 ? 1.1 : 1 / 1.1; |
| this.scale = Math.max(0.05, Math.min(20, this.scale * factor)); |
|
|
| |
| const ratio = this.scale / oldScale; |
| this.offsetX = centerX - (centerX - this.offsetX) * ratio; |
| this.offsetY = centerY - (centerY - this.offsetY) * ratio; |
| } |
|
|
| pan(dx, dy) { |
| this.offsetX += dx; |
| this.offsetY += dy; |
| } |
|
|
| |
| screenToDoc(sx, sy) { |
| return { |
| x: (sx - this.offsetX) / this.scale, |
| y: (sy - this.offsetY) / this.scale, |
| }; |
| } |
|
|
| |
| docToScreen(dx, dy) { |
| return { |
| x: dx * this.scale + this.offsetX, |
| y: dy * this.scale + this.offsetY, |
| }; |
| } |
|
|
| render(strokes, annotations, selectedIndices, highlightedAnnotationId, classes, selectionRect) { |
| const ctx = this.ctx; |
| const dpr = this.dpr; |
|
|
| |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| ctx.fillStyle = '#151525'; |
| ctx.fillRect(0, 0, this.containerWidth, this.containerHeight); |
|
|
| |
| ctx.save(); |
| ctx.translate(this.offsetX, this.offsetY); |
| ctx.scale(this.scale, this.scale); |
|
|
| ctx.fillStyle = '#ffffff'; |
| ctx.shadowColor = 'rgba(0,0,0,0.3)'; |
| ctx.shadowBlur = 10 / this.scale; |
| ctx.fillRect(0, 0, this.pageWidth, this.pageHeight); |
| ctx.shadowColor = 'transparent'; |
|
|
| |
| const strokeClassMap = new Map(); |
| const classMap = new Map(); |
| for (const cls of classes) { |
| classMap.set(cls.name, cls); |
| } |
| for (const ann of annotations) { |
| for (const si of ann.strokeIndices) { |
| strokeClassMap.set(si, ann); |
| } |
| } |
|
|
| |
| for (const stroke of strokes) { |
| const ann = strokeClassMap.get(stroke.index); |
| const isSelected = selectedIndices.has(stroke.index); |
| const isHighlighted = ann && ann.id === highlightedAnnotationId; |
|
|
| let color; |
| let lineWidth = stroke.width; |
|
|
| if (ann) { |
| const cls = classMap.get(ann.className); |
| color = cls ? cls.color : '#888888'; |
| } else { |
| color = '#aaaaaa'; |
| } |
|
|
| if (isSelected) { |
| lineWidth = stroke.width * 1.5; |
| } |
|
|
| if (isHighlighted) { |
| lineWidth = stroke.width * 2; |
| } |
|
|
| this.drawStroke(stroke, color, lineWidth, isSelected); |
| } |
|
|
| ctx.restore(); |
|
|
| |
| if (selectionRect) { |
| ctx.save(); |
| ctx.strokeStyle = '#7c8ef5'; |
| ctx.lineWidth = 2; |
| ctx.setLineDash([6, 3]); |
| ctx.fillStyle = 'rgba(124, 142, 245, 0.1)'; |
| const { x, y, w, h } = selectionRect; |
| ctx.fillRect(x, y, w, h); |
| ctx.strokeRect(x, y, w, h); |
| ctx.restore(); |
| } |
| } |
|
|
| drawStroke(stroke, color, lineWidth, isSelected) { |
| const ctx = this.ctx; |
| const points = stroke.points; |
| if (points.length < 2) return; |
|
|
| ctx.beginPath(); |
| ctx.strokeStyle = color; |
| ctx.lineWidth = lineWidth; |
| ctx.lineCap = 'round'; |
| ctx.lineJoin = 'round'; |
|
|
| ctx.moveTo(points[0].x, points[0].y); |
| for (let i = 1; i < points.length; i++) { |
| ctx.lineTo(points[i].x, points[i].y); |
| } |
| ctx.stroke(); |
|
|
| |
| if (isSelected) { |
| ctx.beginPath(); |
| ctx.strokeStyle = 'rgba(124, 142, 245, 0.4)'; |
| ctx.lineWidth = lineWidth + 4; |
| ctx.moveTo(points[0].x, points[0].y); |
| for (let i = 1; i < points.length; i++) { |
| ctx.lineTo(points[i].x, points[i].y); |
| } |
| ctx.stroke(); |
| } |
| } |
|
|
| |
| hitTestStroke(strokes, sx, sy, tolerance) { |
| const doc = this.screenToDoc(sx, sy); |
| const tolDoc = tolerance / this.scale; |
|
|
| for (let i = strokes.length - 1; i >= 0; i--) { |
| const stroke = strokes[i]; |
| |
| if (doc.x < stroke.bbox.minX - tolDoc || doc.x > stroke.bbox.maxX + tolDoc || |
| doc.y < stroke.bbox.minY - tolDoc || doc.y > stroke.bbox.maxY + tolDoc) { |
| continue; |
| } |
| |
| for (let j = 0; j < stroke.points.length - 1; j++) { |
| const dist = pointToSegmentDist( |
| doc.x, doc.y, |
| stroke.points[j].x, stroke.points[j].y, |
| stroke.points[j + 1].x, stroke.points[j + 1].y |
| ); |
| if (dist < tolDoc + stroke.width / 2) { |
| return stroke.index; |
| } |
| } |
| } |
| return -1; |
| } |
|
|
| |
| strokesInRect(strokes, rectScreen) { |
| const topLeft = this.screenToDoc(rectScreen.x, rectScreen.y); |
| const bottomRight = this.screenToDoc( |
| rectScreen.x + rectScreen.w, |
| rectScreen.y + rectScreen.h |
| ); |
|
|
| const selMinX = Math.min(topLeft.x, bottomRight.x); |
| const selMinY = Math.min(topLeft.y, bottomRight.y); |
| const selMaxX = Math.max(topLeft.x, bottomRight.x); |
| const selMaxY = Math.max(topLeft.y, bottomRight.y); |
|
|
| const result = []; |
| for (const stroke of strokes) { |
| |
| const cx = (stroke.bbox.minX + stroke.bbox.maxX) / 2; |
| const cy = (stroke.bbox.minY + stroke.bbox.maxY) / 2; |
| if (cx >= selMinX && cx <= selMaxX && cy >= selMinY && cy <= selMaxY) { |
| result.push(stroke.index); |
| } |
| } |
| return result; |
| } |
| } |
|
|
| function pointToSegmentDist(px, py, ax, ay, bx, by) { |
| const dx = bx - ax; |
| const dy = by - ay; |
| const lenSq = dx * dx + dy * dy; |
| if (lenSq === 0) return Math.hypot(px - ax, py - ay); |
|
|
| let t = ((px - ax) * dx + (py - ay) * dy) / lenSq; |
| t = Math.max(0, Math.min(1, t)); |
|
|
| const projX = ax + t * dx; |
| const projY = ay + t * dy; |
| return Math.hypot(px - projX, py - projY); |
| } |
|
|