import { layout } from '../stores/layout.svelte.js'; import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, isRectConveyanceType, isExtendoType, getCurveGeometry, getSymbolGroup, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js'; import { checkSpacingViolation } from './collision.js'; import { marqueeRect } from './interactions.js'; import { THEME } from './render-theme.js'; import type { PlacedSymbol } from '../types.js'; let ctx: CanvasRenderingContext2D | null = null; let canvas: HTMLCanvasElement | null = null; let animFrameId: number | null = null; let lastDirty = -1; const MAX_RENDER_SCALE = THEME.canvas.maxRenderScale; let currentRenderScale = 0; let lastCanvasW = 0; let lastCanvasH = 0; export function setCanvas(c: HTMLCanvasElement) { canvas = c; ctx = c.getContext('2d')!; currentRenderScale = 0; // force resolution update on first render } function updateCanvasResolution() { if (!canvas) return; const dpr = window.devicePixelRatio || 1; const targetScale = Math.min(Math.ceil(dpr * layout.zoomLevel), MAX_RENDER_SCALE); const sizeChanged = layout.canvasW !== lastCanvasW || layout.canvasH !== lastCanvasH; if (targetScale === currentRenderScale && !sizeChanged) return; currentRenderScale = targetScale; lastCanvasW = layout.canvasW; lastCanvasH = layout.canvasH; canvas.width = layout.canvasW * currentRenderScale; canvas.height = layout.canvasH * currentRenderScale; canvas.style.width = layout.canvasW + 'px'; canvas.style.height = layout.canvasH + 'px'; } export function startRenderLoop() { function loop() { animFrameId = requestAnimationFrame(loop); const dpr = window.devicePixelRatio || 1; const targetScale = Math.min(Math.ceil(dpr * layout.zoomLevel), MAX_RENDER_SCALE); const sizeChanged = layout.canvasW !== lastCanvasW || layout.canvasH !== lastCanvasH; if (layout.dirty !== lastDirty || targetScale !== currentRenderScale || sizeChanged) { lastDirty = layout.dirty; render(); } } loop(); } export function stopRenderLoop() { if (animFrameId !== null) { cancelAnimationFrame(animFrameId); animFrameId = null; } } export function render() { if (!ctx || !canvas) return; updateCanvasResolution(); ctx.save(); ctx.setTransform(currentRenderScale, 0, 0, currentRenderScale, 0, 0); ctx.clearRect(0, 0, layout.canvasW, layout.canvasH); if (layout.showGrid) { drawGrid(ctx); } // Draw non-overlay symbols first, then overlay symbols (photoeyes/FIOs) on top // Skip hidden symbols (individually hidden or group hidden) for (const sym of layout.symbols) { if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue; if (!SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol); } for (const sym of layout.symbols) { if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue; if (SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol); } // Marquee selection rectangle if (marqueeRect) { ctx.strokeStyle = '#4a9eff'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]); ctx.fillStyle = 'rgba(74, 158, 255, 0.1)'; ctx.fillRect(marqueeRect.x, marqueeRect.y, marqueeRect.w, marqueeRect.h); ctx.strokeRect(marqueeRect.x, marqueeRect.y, marqueeRect.w, marqueeRect.h); ctx.setLineDash([]); } ctx.restore(); } function drawGrid(ctx: CanvasRenderingContext2D) { const size = layout.gridSize; ctx.strokeStyle = THEME.grid.color; ctx.lineWidth = THEME.grid.lineWidth; ctx.beginPath(); for (let x = 0; x <= layout.canvasW; x += size) { ctx.moveTo(x, 0); ctx.lineTo(x, layout.canvasH); } for (let y = 0; y <= layout.canvasH; y += size) { ctx.moveTo(0, y); ctx.lineTo(layout.canvasW, y); } ctx.stroke(); } /** Trace the arc band outline path (for selection/collision/hover strokes on curved types) */ function traceArcBandPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) { const angle = sym.curveAngle || 90; const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); const sweepRad = (angle * Math.PI) / 180; ctx.beginPath(); ctx.arc(arcCx, arcCy, outerR + pad, 0, -sweepRad, true); ctx.arc(arcCx, arcCy, Math.max(0, innerR - pad), -sweepRad, 0, false); ctx.closePath(); } /** Trace the spur trapezoid path (for selection/collision strokes) */ function traceSpurPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) { const w2 = sym.w2 ?? sym.w; // Expand vertices outward by pad ctx.beginPath(); ctx.moveTo(sym.x - pad, sym.y - pad); ctx.lineTo(sym.x + w2 + pad, sym.y - pad); ctx.lineTo(sym.x + sym.w + pad, sym.y + sym.h + pad); ctx.lineTo(sym.x - pad, sym.y + sym.h + pad); ctx.closePath(); } /** Draw the EPC symbol: SVG image for left icon, programmatic polyline + right box. * Both boxes auto-orient along the line direction at their respective ends. */ function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; const ox = sym.x; // origin x const oy = sym.y; // origin y // Draw polyline connecting waypoints if (waypoints.length >= 2) { ctx.beginPath(); ctx.moveTo(ox + waypoints[0].x, oy + waypoints[0].y); for (let i = 1; i < waypoints.length; i++) { ctx.lineTo(ox + waypoints[i].x, oy + waypoints[i].y); } ctx.strokeStyle = THEME.epcBody.lineColor; ctx.lineWidth = EPC_CONFIG.lineWidth; ctx.stroke(); } // --- Left icon: use actual SVG image, oriented along first segment --- if (waypoints.length >= 2) { const lb = EPC_CONFIG.leftBox; const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y; const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y; const angle = Math.atan2(p1y - p0y, p1x - p0x); const iconImg = getSymbolImage(EPC_CONFIG.iconFile); if (iconImg) { ctx.save(); ctx.translate(p0x, p0y); ctx.rotate(angle); ctx.drawImage(iconImg, -lb.w, -lb.h / 2, lb.w, lb.h); ctx.restore(); } } // --- Right box: oriented along direction from wp[n-2] to wp[n-1] --- if (waypoints.length >= 2) { const last = waypoints[waypoints.length - 1]; const prev = waypoints[waypoints.length - 2]; const plx = ox + last.x, ply = oy + last.y; const ppx = ox + prev.x, ppy = oy + prev.y; const angle = Math.atan2(ply - ppy, plx - ppx); ctx.save(); ctx.translate(plx, ply); ctx.rotate(angle); const rb = EPC_CONFIG.rightBox; ctx.fillStyle = THEME.epcBody.rightBoxFill; ctx.strokeStyle = THEME.epcBody.rightBoxStroke; ctx.lineWidth = THEME.epcBody.rightBoxStrokeWidth; ctx.fillRect(-rb.w, -rb.h / 2, rb.w, rb.h); ctx.strokeRect(-rb.w, -rb.h / 2, rb.w, rb.h); ctx.restore(); } } /** Draw EPC waypoint handles when selected */ function drawEpcWaypointHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; const hs = THEME.epcWaypoint.size; ctx.fillStyle = THEME.epcWaypoint.fillColor; ctx.strokeStyle = THEME.epcWaypoint.strokeColor; ctx.lineWidth = THEME.epcWaypoint.lineWidth; for (const wp of waypoints) { const hx = sym.x + wp.x; const hy = sym.y + wp.y; ctx.beginPath(); ctx.arc(hx, hy, hs / 2, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } // Draw "+" at midpoints of segments to hint add-waypoint if (waypoints.length >= 2) { ctx.fillStyle = THEME.epcWaypoint.fillColor; ctx.font = THEME.epcWaypoint.hintFont; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let i = 0; i < waypoints.length - 1; i++) { const mx = sym.x + (waypoints[i].x + waypoints[i + 1].x) / 2; const my = sym.y + (waypoints[i].y + waypoints[i + 1].y) / 2; ctx.fillText('+', mx, my + THEME.epcWaypoint.hintOffsetY); } } } /** Trace the EPC outline path: left box + segments + right box */ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; if (waypoints.length < 2) return; const ox = sym.x, oy = sym.y; // Draw left box outline const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y; const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y; const lAngle = Math.atan2(p1y - p0y, p1x - p0x); const lb = EPC_CONFIG.leftBox; ctx.save(); ctx.translate(p0x, p0y); ctx.rotate(lAngle); ctx.beginPath(); ctx.rect(-lb.w - pad, -lb.h / 2 - pad, lb.w + pad * 2, lb.h + pad * 2); ctx.stroke(); ctx.restore(); // Draw line segments outline (thickened) for (let i = 0; i < waypoints.length - 1; i++) { const ax = ox + waypoints[i].x, ay = oy + waypoints[i].y; const bx = ox + waypoints[i + 1].x, by = oy + waypoints[i + 1].y; const segAngle = Math.atan2(by - ay, bx - ax); const segLen = Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2); ctx.save(); ctx.translate(ax, ay); ctx.rotate(segAngle); ctx.beginPath(); ctx.rect(-pad, -pad - EPC_CONFIG.lineWidth / 2, segLen + pad * 2, EPC_CONFIG.lineWidth + pad * 2); ctx.stroke(); ctx.restore(); } // Draw right box outline const last = waypoints[waypoints.length - 1]; const prev = waypoints[waypoints.length - 2]; const plx = ox + last.x, ply = oy + last.y; const ppx = ox + prev.x, ppy = oy + prev.y; const rAngle = Math.atan2(ply - ppy, plx - ppx); const rb = EPC_CONFIG.rightBox; ctx.save(); ctx.translate(plx, ply); ctx.rotate(rAngle); ctx.beginPath(); ctx.rect(-rb.w - pad, -rb.h / 2 - pad, rb.w + pad * 2, rb.h + pad * 2); ctx.stroke(); ctx.restore(); } /** Trace the induction outline path (arrow head + strip) */ function traceInductionPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { const hw = INDUCTION_CONFIG.headWidth; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); ctx.beginPath(); ctx.moveTo(sym.x + sym.w + pad, stripTopY - pad); ctx.lineTo(pts[0][0], stripTopY - pad); // Arrow outline with padding for (let i = 0; i < pts.length; i++) { const [px, py] = pts[i]; // Simple approach: offset each point outward by pad ctx.lineTo(px + (i <= 2 ? -pad : pad), py + (i <= 1 ? -pad : pad)); } ctx.lineTo(pts[5][0], stripBottomY + pad); ctx.lineTo(sym.x + sym.w + pad, stripBottomY + pad); ctx.closePath(); } /** Trace photoeye outline path */ function tracePhotoeyePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { const { leftCap, rightCap } = PHOTOEYE_CONFIG; const x = sym.x, y = sym.y, w = sym.w, h = sym.h; const p = pad; ctx.beginPath(); ctx.moveTo(x + leftCap + p, y + h * 0.42 - p); ctx.lineTo(x + leftCap + p, y + h * 0.248 - p); ctx.lineTo(x - p, y + h * 0.05 - p); ctx.lineTo(x - p, y + h * 0.948 + p); ctx.lineTo(x + leftCap + p, y + h * 0.744 + p); ctx.lineTo(x + leftCap + p, y + h * 0.585 + p); ctx.lineTo(x + w - rightCap - p, y + h * 0.585 + p); ctx.lineTo(x + w - rightCap - p, y + h * 0.826 + p); ctx.lineTo(x + w + p, y + h * 0.826 + p); ctx.lineTo(x + w + p, y + h * 0.181 - p); ctx.lineTo(x + w - rightCap - p, y + h * 0.181 - p); ctx.lineTo(x + w - rightCap - p, y + h * 0.42 - p); ctx.closePath(); } /** Stroke an outline around a symbol — uses arc path for curved, trapezoid for spur, EPC shape for EPC, induction shape for induction, rect for straight */ function strokeOutline(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { if (isCurvedType(sym.symbolId)) { traceArcBandPath(ctx, sym, pad); ctx.stroke(); } else if (isSpurType(sym.symbolId)) { traceSpurPath(ctx, sym, pad); ctx.stroke(); } else if (isEpcType(sym.symbolId)) { traceEpcOutlinePath(ctx, sym, pad); } else if (isInductionType(sym.symbolId)) { traceInductionPath(ctx, sym, pad); ctx.stroke(); } else if (isPhotoeyeType(sym.symbolId)) { tracePhotoeyePath(ctx, sym, pad); ctx.stroke(); } else { ctx.strokeRect(sym.x - pad, sym.y - pad, sym.w + pad * 2, sym.h + pad * 2); } } /** Draw a filled+stroked resize handle at (x, y) */ function drawHandle(ctx: CanvasRenderingContext2D, x: number, y: number, size: number) { const half = size / 2; ctx.fillRect(x - half, y - half, size, size); ctx.strokeRect(x - half, y - half, size, size); } function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { if (!isResizable(sym.symbolId)) return; const hs = THEME.resizeHandle.size; ctx.fillStyle = THEME.resizeHandle.fillColor; ctx.strokeStyle = THEME.resizeHandle.strokeColor; ctx.lineWidth = THEME.resizeHandle.lineWidth; if (isCurvedType(sym.symbolId)) { const { arcCx, arcCy, outerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); drawHandle(ctx, arcCx + outerR, arcCy, hs); } else if (isSpurType(sym.symbolId)) { const w2 = sym.w2 ?? sym.w; // Right handle on top base (controls w2) drawHandle(ctx, sym.x + w2, sym.y, hs); // Right handle on bottom base (controls w) drawHandle(ctx, sym.x + sym.w, sym.y + sym.h, hs); } else if (isInductionType(sym.symbolId)) { // Only right handle — arrow head is fixed width const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; const stripMidY = (stripTopY + stripBottomY) / 2; drawHandle(ctx, sym.x + sym.w, stripMidY, hs); } else { const midY = sym.y + sym.h / 2; drawHandle(ctx, sym.x, midY, hs); drawHandle(ctx, sym.x + sym.w, midY, hs); } } /** Draw induction programmatically: fixed arrow head + variable strip, as one path */ function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const hw = INDUCTION_CONFIG.headWidth; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; // Arrow points in display coords const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); ctx.beginPath(); // Top-right of strip ctx.moveTo(sym.x + sym.w, stripTopY); // Top-left junction (arrow meets strip) ctx.lineTo(pts[0][0], stripTopY); // Arrow outline for (const [px, py] of pts) { ctx.lineTo(px, py); } // Bottom-left junction to strip bottom ctx.lineTo(pts[5][0], stripBottomY); // Bottom-right of strip ctx.lineTo(sym.x + sym.w, stripBottomY); ctx.closePath(); ctx.fillStyle = THEME.induction.fillColor; ctx.strokeStyle = THEME.induction.strokeColor; ctx.lineWidth = THEME.induction.lineWidth; ctx.fill(); ctx.stroke(); } /** Draw photoeye programmatically for consistent stroke at any size */ function drawPhotoeyeSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const { leftCap, rightCap } = PHOTOEYE_CONFIG; const x = sym.x, y = sym.y, w = sym.w, h = sym.h; // Y positions as fractions of height (derived from original SVG path) const beamTop = y + h * 0.42; const beamBottom = y + h * 0.585; const arrowInnerTop = y + h * 0.248; const arrowInnerBottom = y + h * 0.744; const recvTop = y + h * 0.181; const recvBottom = y + h * 0.826; const arrowTipTop = y + h * 0.05; const arrowTipBottom = y + h * 0.948; ctx.beginPath(); ctx.moveTo(x + leftCap, beamTop); ctx.lineTo(x + leftCap, arrowInnerTop); ctx.lineTo(x, arrowTipTop); ctx.lineTo(x, arrowTipBottom); ctx.lineTo(x + leftCap, arrowInnerBottom); ctx.lineTo(x + leftCap, beamBottom); ctx.lineTo(x + w - rightCap, beamBottom); ctx.lineTo(x + w - rightCap, recvBottom); ctx.lineTo(x + w, recvBottom); ctx.lineTo(x + w, recvTop); ctx.lineTo(x + w - rightCap, recvTop); ctx.lineTo(x + w - rightCap, beamTop); ctx.closePath(); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; ctx.fill(); ctx.stroke(); } /** Draw curved conveyor/chute programmatically with fixed band width */ function drawCurvedSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const angle = sym.curveAngle || 90; const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); const sweepRad = (angle * Math.PI) / 180; ctx.beginPath(); ctx.arc(arcCx, arcCy, outerR, 0, -sweepRad, true); ctx.arc(arcCx, arcCy, innerR, -sweepRad, 0, false); ctx.closePath(); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; ctx.fill(); ctx.stroke(); } function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boolean { if (isEpcType(sym.symbolId)) { drawEpcSymbol(ctx, sym); } else if (isInductionType(sym.symbolId)) { drawInductionSymbol(ctx, sym); } else if (isCurvedType(sym.symbolId)) { drawCurvedSymbol(ctx, sym); } else if (isSpurType(sym.symbolId)) { // Draw trapezoid programmatically so w and w2 are respected during resize const w2 = sym.w2 ?? sym.w; ctx.beginPath(); ctx.moveTo(sym.x, sym.y); ctx.lineTo(sym.x + w2, sym.y); ctx.lineTo(sym.x + sym.w, sym.y + sym.h); ctx.lineTo(sym.x, sym.y + sym.h); ctx.closePath(); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; ctx.fill(); ctx.stroke(); } else if (isExtendoType(sym.symbolId)) { // Extendo: fixed left bracket + stretchy right belt // Y fractions from original SVG path, X uses fixed left bracket width const bracketW = 10.6 / 31.07 * 73; // ~24.9 px at default 73w — fixed portion const x = sym.x, y = sym.y, w = sym.w, h = sym.h; ctx.beginPath(); ctx.moveTo(x + bracketW * 0.44, y + h * 0.085); // tab top-right ctx.lineTo(x + bracketW, y + h * 0.085); // bracket top-right ctx.lineTo(x + bracketW, y + h * 0.222); // step down to belt top ctx.lineTo(x + w, y + h * 0.222); // belt top-right ctx.lineTo(x + w, y + h * 0.780); // belt bottom-right ctx.lineTo(x + bracketW, y + h * 0.780); // belt bottom-left ctx.lineTo(x + bracketW, y + h * 0.917); // step down bracket bottom ctx.lineTo(x + bracketW * 0.44, y + h * 0.916); // bracket bottom-left ctx.lineTo(x + bracketW * 0.34, y + h * 0.985); // notch bottom ctx.lineTo(x, y + h * 0.980); // far left bottom ctx.lineTo(x, y + h * 0.017); // far left top ctx.lineTo(x + bracketW * 0.34, y + h * 0.016); // tab top-left ctx.closePath(); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; ctx.fill(); ctx.stroke(); } else if (isRectConveyanceType(sym.symbolId)) { ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; ctx.fillRect(sym.x, sym.y, sym.w, sym.h); ctx.strokeRect(sym.x, sym.y, sym.w, sym.h); } else if (isPhotoeyeType(sym.symbolId)) { drawPhotoeyeSymbol(ctx, sym); } else { const img = getSymbolImage(sym.file); if (!img) return false; ctx.drawImage(img, sym.x, sym.y, sym.w, sym.h); } return true; } /** Parse a conveyance label like "UL17_22_VFD" into display lines. * Strips known suffixes (_VFD, _VFD1, etc.), splits prefix from numbers, * replaces underscores with dashes in the number part. */ function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } { // Strip known device suffixes let core = label.replace(/_?VFD\d*$/i, ''); if (core === label) core = label; // no suffix found, use as-is // Split into letter prefix and rest const m = core.match(/^([A-Za-z]+)(.*)$/); if (m) { const prefix = m[1]; const nums = m[2].replace(/_/g, '-').replace(/^-/, ''); if (nums) { return { lines: [prefix, nums], stripped: [nums] }; } return { lines: [prefix], stripped: [prefix] }; } return { lines: [core], stripped: [core] }; } /** Draw label inside a conveyance symbol — black bold text, auto-sized to fit */ function drawConveyanceLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { if (!sym.label) return; if (isCurvedType(sym.symbolId)) { drawCurvedLabel(ctx, sym); return; } const { lines, stripped } = parseConveyanceLabel(sym.label); const pad = 2; // For spurs, center in the trapezoid shape, not bounding box let cx: number, cy: number, availW: number, availH: number; if (isSpurType(sym.symbolId)) { const w2 = sym.w2 ?? sym.w; // Center text in the right half (wide area) of the trapezoid cx = sym.x + (w2 + sym.w) / 2; cy = sym.y + sym.h / 2; availW = sym.w; availH = sym.h; } else { cx = sym.x + sym.w / 2; cy = sym.y + sym.h / 2; availW = sym.w - pad * 2; availH = sym.h - pad * 2; } // Compute text correction so text is always readable. // Mirror flips the effective angle: visual angle = (360 - rot) when mirrored. const rot = ((sym.rotation || 0) % 360 + 360) % 360; const effectiveAngle = sym.mirrored ? ((360 - rot) % 360) : rot; const needsFlip = effectiveAngle > 90 && effectiveAngle < 270; const needsMirrorFix = !!sym.mirrored; const hasCorrection = needsFlip || needsMirrorFix; ctx.fillStyle = '#000000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (const tryLines of [lines, stripped]) { let fontSize = 14; const lineCount = tryLines.length; const totalTextH = () => fontSize * lineCount + (lineCount - 1) * 1; while (totalTextH() > availH && fontSize > 4) fontSize -= 0.5; ctx.font = `bold ${fontSize}px Arial`; let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width)); while (maxW > availW && fontSize > 4) { fontSize -= 0.5; ctx.font = `bold ${fontSize}px Arial`; maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width)); } if (fontSize >= 4) { ctx.font = `bold ${fontSize}px Arial`; const lineH = fontSize; if (hasCorrection) { ctx.save(); ctx.translate(cx, cy); if (needsMirrorFix) ctx.scale(-1, 1); if (needsFlip) ctx.rotate(Math.PI); for (let i = 0; i < tryLines.length; i++) { const dy = -(lineCount - 1) * lineH / 2 + i * lineH; ctx.fillText(tryLines[i], 0, dy); } ctx.restore(); } else { const startY = cy - (lineCount - 1) * lineH / 2; for (let i = 0; i < tryLines.length; i++) { ctx.fillText(tryLines[i], cx, startY + i * lineH); } } return; } } } /** Draw label along a curved symbol's arc band */ function drawCurvedLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { if (!sym.label) return; const { lines, stripped } = parseConveyanceLabel(sym.label); const angle = sym.curveAngle || 90; const { arcCx, arcCy, outerR, innerR, bandW } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); const midR = (outerR + innerR) / 2; const midAngleRad = ((angle / 2) * Math.PI) / 180; // half the sweep angle // Position at arc midpoint const textX = arcCx + midR * Math.cos(midAngleRad); const textY = arcCy - midR * Math.sin(midAngleRad); // Rotation: tangent to arc let textRot = -midAngleRad + Math.PI / 2; // Add symbol rotation to check readability const symRotRad = ((sym.rotation || 0) * Math.PI) / 180; let worldAngle = (textRot + symRotRad) % (2 * Math.PI); if (worldAngle < 0) worldAngle += 2 * Math.PI; // Flip if text would be upside-down if (worldAngle > Math.PI / 2 && worldAngle < Math.PI * 3 / 2) { textRot += Math.PI; } // Mirror: label follows the mirrored shape naturally const availW = bandW - 4; ctx.save(); ctx.translate(textX, textY); ctx.rotate(textRot); ctx.fillStyle = '#000000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (const tryLines of [lines, stripped]) { let fontSize = 14; const lineCount = tryLines.length; while (fontSize * lineCount > availW && fontSize > 4) fontSize -= 0.5; ctx.font = `bold ${fontSize}px Arial`; let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width)); // Available length along the arc at midR const arcLen = midR * (angle * Math.PI / 180) * 0.6; // use 60% of arc while (maxW > arcLen && fontSize > 4) { fontSize -= 0.5; ctx.font = `bold ${fontSize}px Arial`; maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width)); } if (fontSize >= 4) { const lineH = fontSize; const startY = -(lineCount - 1) * lineH / 2; for (let i = 0; i < tryLines.length; i++) { ctx.fillText(tryLines[i], 0, startY + i * lineH); } ctx.restore(); return; } } ctx.restore(); } function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const cx = sym.x + sym.w / 2; const isSelected = layout.selectedIds.has(sym.id); // Selection highlight if (isSelected) { ctx.strokeStyle = THEME.selection.strokeColor; ctx.lineWidth = THEME.selection.lineWidth; ctx.shadowColor = THEME.selection.shadowColor; ctx.shadowBlur = THEME.selection.shadowBlur; strokeOutline(ctx, sym, THEME.selection.pad); ctx.shadowBlur = 0; if (layout.selectedIds.size === 1) { if (isEpcType(sym.symbolId)) { drawEpcWaypointHandles(ctx, sym); } else { drawResizeHandles(ctx, sym); } } } // Collision highlight if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2, sym.mirrored)) { ctx.strokeStyle = THEME.collision.strokeColor; ctx.lineWidth = THEME.collision.lineWidth; ctx.shadowColor = THEME.collision.shadowColor; ctx.shadowBlur = THEME.collision.shadowBlur; strokeOutline(ctx, sym, THEME.collision.pad); ctx.shadowBlur = 0; } // Label drop-target highlight if (layout.labelDropTarget === sym.id) { ctx.fillStyle = THEME.dropTarget.fillColor; ctx.strokeStyle = THEME.dropTarget.strokeColor; ctx.lineWidth = THEME.dropTarget.lineWidth; ctx.shadowColor = THEME.dropTarget.shadowColor; ctx.shadowBlur = THEME.dropTarget.shadowBlur; // Fill + stroke the outline ctx.beginPath(); ctx.rect(sym.x - THEME.dropTarget.pad, sym.y - THEME.dropTarget.pad, sym.w + THEME.dropTarget.pad * 2, sym.h + THEME.dropTarget.pad * 2); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; } // Hover border (non-selected) if (!isSelected && layout.labelDropTarget !== sym.id) { ctx.strokeStyle = THEME.hover.strokeColor; ctx.lineWidth = THEME.hover.lineWidth; strokeOutline(ctx, sym, 0); } // Label rendering if (sym.label) { const isConveyance = isRectConveyanceType(sym.symbolId) || isExtendoType(sym.symbolId) || isCurvedType(sym.symbolId) || isSpurType(sym.symbolId) || isInductionType(sym.symbolId); if (isConveyance) { drawConveyanceLabel(ctx, sym); } else { ctx.fillStyle = THEME.label.color; ctx.font = THEME.label.font; const metrics = ctx.measureText(sym.label); const textH = 3; if (sym.w >= metrics.width + 4 && sym.h >= textH + 4) { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(sym.label, cx, sym.y + sym.h / 2); } else { ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY); } } } } function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { ctx.save(); // Apply rotation and mirror transforms if (sym.rotation || sym.mirrored) { const cx = sym.x + sym.w / 2; const cy = sym.y + sym.h / 2; ctx.translate(cx, cy); if (sym.rotation) ctx.rotate((sym.rotation * Math.PI) / 180); if (sym.mirrored) ctx.scale(-1, 1); ctx.translate(-cx, -cy); } if (!drawSymbolBody(ctx, sym)) { ctx.restore(); return; } drawSymbolOverlays(ctx, sym); ctx.restore(); }