Download renderer.js from PellelNitram/xournalpp_htr_data_annotation_tool: direct link, hf CLI and curl.
- Browser
- Download file 8.97 kB
-
https://huggingface.co/spaces/PellelNitram/xournalpp_htr_data_annotation_tool/resolve/main/renderer.js
- Command line
-
hf download hf://spaces/PellelNitram/xournalpp_htr_data_annotation_tool/renderer.js
-
curl -L -o renderer.js https://huggingface.co/spaces/PellelNitram/xournalpp_htr_data_annotation_tool/resolve/main/renderer.js
8.97 kB
| // renderer.js - Canvas rendering with pan/zoom and stroke display | |
| 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)); | |
| // Zoom toward the cursor position | |
| 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; | |
| } | |
| // Convert screen coordinates to document coordinates | |
| screenToDoc(sx, sy) { | |
| return { | |
| x: (sx - this.offsetX) / this.scale, | |
| y: (sy - this.offsetY) / this.scale, | |
| }; | |
| } | |
| // Convert document coordinates to screen coordinates | |
| docToScreen(dx, dy) { | |
| return { | |
| x: dx * this.scale + this.offsetX, | |
| y: dy * this.scale + this.offsetY, | |
| }; | |
| } | |
| render(strokes, annotations, selectedIndices, highlightedAnnotationId, classes, selectionRect, highlightUnassigned = false) { | |
| const ctx = this.ctx; | |
| const dpr = this.dpr; | |
| // Clear | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| ctx.fillStyle = '#151525'; | |
| ctx.fillRect(0, 0, this.containerWidth, this.containerHeight); | |
| // Draw page background | |
| 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'; | |
| // Build stroke-to-class map | |
| 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); | |
| } | |
| } | |
| // Draw strokes | |
| 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 = highlightUnassigned ? '#ff2d55' : '#aaaaaa'; | |
| } | |
| if (isSelected) { | |
| lineWidth = stroke.width * 1.5; | |
| } | |
| if (isHighlighted) { | |
| lineWidth = stroke.width * 2; | |
| } | |
| if (highlightUnassigned && !ann) { | |
| lineWidth = Math.max(lineWidth, stroke.width * 2); | |
| this.drawUnassignedHalo(stroke, lineWidth); | |
| } | |
| // Dim annotated strokes so the unassigned ones stand out | |
| ctx.globalAlpha = highlightUnassigned && ann ? 0.25 : 1; | |
| this.drawStroke(stroke, color, lineWidth, isSelected); | |
| ctx.globalAlpha = 1; | |
| } | |
| ctx.restore(); | |
| // Draw selection rectangle (in screen coordinates) | |
| 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(); | |
| } | |
| } | |
| drawUnassignedHalo(stroke, lineWidth) { | |
| const ctx = this.ctx; | |
| const points = stroke.points; | |
| if (points.length < 2) return; | |
| ctx.beginPath(); | |
| ctx.strokeStyle = 'rgba(255, 45, 85, 0.3)'; | |
| ctx.lineWidth = lineWidth + 6 / this.scale; | |
| 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(); | |
| } | |
| 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(); | |
| // Draw selection highlight halo | |
| 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(); | |
| } | |
| } | |
| // Hit test: check if a screen point is near any 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]; | |
| // Quick bbox check | |
| 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; | |
| } | |
| // Detailed point-to-segment distance check | |
| 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; | |
| } | |
| // Find all strokes whose bbox intersects the selection rectangle (in screen coords) | |
| 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) { | |
| // Check if stroke bbox center is inside selection rect | |
| 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); | |
| } | |